@estiva-app/ui 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +38 -2
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Button.d.ts +6 -1
  5. package/dist/Button.d.ts.map +1 -1
  6. package/dist/Card.d.ts +6 -1
  7. package/dist/Card.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Divider.d.ts +7 -0
  10. package/dist/Divider.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +12 -2
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/IdentityMenu.d.ts.map +1 -1
  14. package/dist/Link.d.ts +6 -1
  15. package/dist/Link.d.ts.map +1 -1
  16. package/dist/Menu.d.ts.map +1 -1
  17. package/dist/Popover.d.ts.map +1 -1
  18. package/dist/ReactionPicker.d.ts.map +1 -1
  19. package/dist/SectionLabel.d.ts +6 -1
  20. package/dist/SectionLabel.d.ts.map +1 -1
  21. package/dist/Tooltip.d.ts.map +1 -1
  22. package/dist/eslint/index.d.ts +2 -0
  23. package/dist/eslint/index.d.ts.map +1 -1
  24. package/dist/eslint/index.js +514 -6
  25. package/dist/eslint/index.js.map +4 -4
  26. package/dist/eslint/no-restyled-part.d.ts +81 -0
  27. package/dist/eslint/no-restyled-part.d.ts.map +1 -0
  28. package/dist/gates/app-checks.d.ts +40 -0
  29. package/dist/gates/app-checks.d.ts.map +1 -0
  30. package/dist/gates/chunk-AWO7SZSA.js +477 -0
  31. package/dist/gates/chunk-AWO7SZSA.js.map +7 -0
  32. package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
  33. package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
  34. package/dist/gates/cli.d.ts +2 -0
  35. package/dist/gates/cli.d.ts.map +1 -0
  36. package/dist/gates/cli.js +46 -0
  37. package/dist/gates/cli.js.map +7 -0
  38. package/dist/gates/count.d.ts +20 -0
  39. package/dist/gates/count.d.ts.map +1 -0
  40. package/dist/gates/create-app-cli.d.ts +2 -0
  41. package/dist/gates/create-app-cli.d.ts.map +1 -0
  42. package/dist/gates/create-app.d.ts +23 -0
  43. package/dist/gates/create-app.d.ts.map +1 -0
  44. package/dist/gates/create-app.js +30 -0
  45. package/dist/gates/create-app.js.map +7 -0
  46. package/dist/gates/gate-config.d.ts +44 -0
  47. package/dist/gates/gate-config.d.ts.map +1 -0
  48. package/dist/gates/hook.d.ts +18 -0
  49. package/dist/gates/hook.d.ts.map +1 -0
  50. package/dist/gates/index.d.ts +30 -0
  51. package/dist/gates/index.d.ts.map +1 -0
  52. package/dist/gates/index.js +415 -0
  53. package/dist/gates/index.js.map +7 -0
  54. package/dist/gates/status.d.ts +84 -0
  55. package/dist/gates/status.d.ts.map +1 -0
  56. package/dist/gates/token-lint.d.ts +26 -0
  57. package/dist/gates/token-lint.d.ts.map +1 -0
  58. package/dist/index.js +290 -266
  59. package/dist/index.js.map +4 -4
  60. package/package.json +30 -4
  61. package/src/AttachmentCard.tsx +7 -2
  62. package/src/Banner.tsx +2 -2
  63. package/src/Breadcrumb.tsx +9 -4
  64. package/src/Button.mdx +7 -0
  65. package/src/Button.stories.tsx +4 -1
  66. package/src/Button.tsx +10 -2
  67. package/src/Card.mdx +7 -0
  68. package/src/Card.stories.tsx +11 -0
  69. package/src/Card.tsx +8 -0
  70. package/src/CommandPalette.tsx +1 -1
  71. package/src/ConfirmDialog.tsx +2 -2
  72. package/src/DialogShell.tsx +6 -7
  73. package/src/Divider.mdx +3 -0
  74. package/src/Divider.tsx +12 -2
  75. package/src/EditableText.mdx +5 -0
  76. package/src/IconButton.mdx +9 -0
  77. package/src/IconButton.stories.tsx +10 -1
  78. package/src/IconButton.tsx +22 -3
  79. package/src/IdentityMenu.tsx +4 -1
  80. package/src/Link.mdx +6 -0
  81. package/src/Link.stories.tsx +12 -0
  82. package/src/Link.tsx +7 -2
  83. package/src/Menu.stories.tsx +1 -1
  84. package/src/Menu.test.tsx +2 -2
  85. package/src/Menu.tsx +6 -3
  86. package/src/Person.stories.tsx +5 -5
  87. package/src/Popover.test.tsx +2 -2
  88. package/src/Popover.tsx +3 -2
  89. package/src/PreviewCard.tsx +1 -1
  90. package/src/Property.stories.tsx +2 -2
  91. package/src/ReactionPicker.tsx +6 -3
  92. package/src/ScrollArea.stories.tsx +21 -7
  93. package/src/SectionLabel.mdx +3 -2
  94. package/src/SectionLabel.stories.tsx +3 -0
  95. package/src/SectionLabel.tsx +7 -2
  96. package/src/Tooltip.tsx +12 -14
  97. package/src/eslint/index.test.ts +32 -7
  98. package/src/eslint/index.ts +10 -1
  99. package/src/eslint/no-restyled-part.test.ts +301 -0
  100. package/src/eslint/no-restyled-part.ts +647 -0
  101. package/src/gates/app-checks.ts +224 -0
  102. package/src/gates/cli.ts +55 -0
  103. package/src/gates/count.ts +76 -0
  104. package/src/gates/create-app-cli.ts +25 -0
  105. package/src/gates/create-app.test.ts +72 -0
  106. package/src/gates/create-app.ts +797 -0
  107. package/src/gates/gate-config.ts +78 -0
  108. package/src/gates/gates.test.ts +183 -0
  109. package/src/gates/hook.ts +111 -0
  110. package/src/gates/index.ts +30 -0
  111. package/src/gates/status.ts +532 -0
  112. package/src/gates/token-lint.ts +231 -0
@@ -0,0 +1,532 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * gates:status — where the UI Guardrails project stands, read from the code.
4
+ *
5
+ * estiva-gates status one row per ticket
6
+ * estiva-gates status --detail every check under every row
7
+ * estiva-gates status --json machine output, read by estiva-ui's run
8
+ *
9
+ * Every row is decided by checks on real files, a real lint run or a real
10
+ * GitHub setting. Nothing here reads a list that someone ticks by hand.
11
+ *
12
+ * **One copy, in the package** (docs/GATES.md §23). Until UIG-10 this engine was
13
+ * `scripts/gates-status.mjs`, the same file copied by hand into estiva-ui, Peek
14
+ * and Ship, with estiva-ui warning when the copies drifted. Now it ships here,
15
+ * and a repo runs it with `estiva-gates status`. A repo still carrying its own
16
+ * copy is named as one when estiva-ui runs it; UIG-32 moves Peek and Ship off
17
+ * theirs.
18
+ *
19
+ * What differs per repo is `scripts/gates-checks.mjs`, in the repo: its checks,
20
+ * or the checks every app runs (`appChecks`) plus its own.
21
+ *
22
+ * The rows and the reasons for each check are in estiva-ui docs/GATES.md §15
23
+ * and §17.
24
+ */
25
+ import { execFileSync } from 'node:child_process'
26
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
27
+ import { createRequire } from 'node:module'
28
+ import { join, relative, resolve } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
30
+ import type { ESLint as ESLintClass } from 'eslint'
31
+
32
+ export type CheckResult = { result: 'pass' | 'fail' | 'part' | 'unknown'; detail: string }
33
+ type Maybe<T> = T | Promise<T>
34
+
35
+ export interface LintProbe {
36
+ /** The folder ESLint runs from, relative to the repo: `web` in Ship. */
37
+ cwd?: string
38
+ /** The config file, relative to `cwd`. */
39
+ config: string
40
+ /** The path the text is linted as, relative to `cwd`. Nothing is written. */
41
+ file: string
42
+ code: string
43
+ expect: 'error' | 'warning' | 'none'
44
+ /** Words the expected message must contain. */
45
+ mentions?: string
46
+ }
47
+
48
+ /** The helpers a checks file is written with. */
49
+ export interface GateHelpers {
50
+ PASS(detail: string): CheckResult
51
+ FAIL(detail: string): CheckResult
52
+ PART(detail: string): CheckResult
53
+ UNKNOWN(detail: string): CheckResult
54
+ exists(rel: string): boolean
55
+ read(rel: string): string
56
+ listFiles(rel: string, test: (name: string, path: string) => boolean): string[]
57
+ file(rel: string): CheckResult
58
+ committed(rel: string): CheckResult
59
+ contains(rel: string, needle: string | RegExp, label?: string): CheckResult
60
+ lacks(rel: string, needle: string | RegExp, label?: string): CheckResult
61
+ script(pkgRel: string, name: string): CheckResult
62
+ loads(rel: string): Promise<CheckResult>
63
+ ci(script: string | RegExp): CheckResult
64
+ ciJob(job: string, script: string): CheckResult
65
+ hook(settingsRel: string, needle: string): CheckResult
66
+ json(rel: string, test?: (data: unknown) => boolean, label?: string): CheckResult
67
+ lint(probe: LintProbe): Promise<CheckResult>
68
+ protectedBranch(pattern: RegExp): CheckResult
69
+ gh(args: string[], label: string): CheckResult
70
+ share(files: string[], test: (file: string) => boolean, label: string): CheckResult
71
+ }
72
+
73
+ export interface GateCheck {
74
+ what: string
75
+ run: () => Maybe<CheckResult>
76
+ aggregate?: boolean
77
+ }
78
+ export interface GateTicket {
79
+ ref: string
80
+ title?: string
81
+ owner: boolean
82
+ checks: GateCheck[]
83
+ }
84
+ export interface TicketListEntry {
85
+ ref: string
86
+ owner: string
87
+ title: string
88
+ parts?: string[]
89
+ aggregate?: boolean
90
+ }
91
+ export interface GateSpec {
92
+ repo: string
93
+ tickets: GateTicket[]
94
+ /** estiva-ui only: every ticket, and the repos it joins in. */
95
+ all?: TicketListEntry[]
96
+ siblings?: { name: string; path: string; env: string }[]
97
+ }
98
+
99
+ interface Row {
100
+ ref: string
101
+ title?: string
102
+ owner?: boolean
103
+ ownerRepo?: string
104
+ checks: (CheckResult & { repo: string; what: string })[]
105
+ status?: string
106
+ aggregate?: boolean
107
+ }
108
+ interface Report {
109
+ schema: 1
110
+ repo: string
111
+ branch: string | null
112
+ commit: string | null
113
+ engine: string
114
+ rows: Row[]
115
+ }
116
+
117
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
118
+ /** What a report says ran it. A copy of the old script reports a hash instead. */
119
+ export const ENGINE = `@estiva-app/ui@${version}`
120
+
121
+ const PASS = (detail: string): CheckResult => ({ result: 'pass', detail })
122
+ const FAIL = (detail: string): CheckResult => ({ result: 'fail', detail })
123
+ const PART = (detail: string): CheckResult => ({ result: 'part', detail })
124
+ const UNKNOWN = (detail: string): CheckResult => ({ result: 'unknown', detail })
125
+ const said = (e: unknown) => {
126
+ const err = e as { stdout?: unknown; stderr?: unknown; message?: string; code?: string }
127
+ return { text: `${err.stdout ?? ''}${err.stderr ?? ''}`, message: String(err.message ?? e), code: err.code }
128
+ }
129
+
130
+ /** The helpers, bound to one repository. */
131
+ export function helpers(ROOT: string): GateHelpers {
132
+ const abs = (rel: string) => resolve(ROOT, rel)
133
+ const exists = (rel: string) => existsSync(abs(rel))
134
+ const read = (rel: string) => readFileSync(abs(rel), 'utf8')
135
+ const git = (...a: string[]) => {
136
+ try {
137
+ return execFileSync('git', a, { cwd: ROOT, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
138
+ } catch {
139
+ return null
140
+ }
141
+ }
142
+ const listFiles = (rel: string, test: (name: string, path: string) => boolean) => {
143
+ const out: string[] = []
144
+ const walk = (dir: string) => {
145
+ if (!existsSync(dir)) return
146
+ for (const name of readdirSync(dir)) {
147
+ if (['node_modules', '.git', 'dist', 'storybook-static', '.verify-shots'].includes(name)) continue
148
+ const p = join(dir, name)
149
+ if (statSync(p).isDirectory()) walk(p)
150
+ else if (test(name, p)) out.push(relative(ROOT, p).replace(/\\/g, '/'))
151
+ }
152
+ }
153
+ walk(abs(rel))
154
+ return out
155
+ }
156
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
157
+
158
+ return {
159
+ PASS, FAIL, PART, UNKNOWN, exists, read, listFiles,
160
+
161
+ file(rel) {
162
+ return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`)
163
+ },
164
+
165
+ committed(rel) {
166
+ if (!exists(rel)) return FAIL(`${rel} does not exist`)
167
+ return git('ls-files', '--error-unmatch', rel) ? PASS(`${rel} is committed`) : FAIL(`${rel} exists but is not committed`)
168
+ },
169
+
170
+ contains(rel, needle, label) {
171
+ if (!exists(rel)) return FAIL(`${rel} does not exist`)
172
+ const hit = typeof needle === 'string' ? read(rel).includes(needle) : needle.test(read(rel))
173
+ return hit ? PASS(label ?? `${rel} has ${needle}`) : FAIL(label ? `not yet: ${label}` : `${rel} has no ${needle}`)
174
+ },
175
+
176
+ lacks(rel, needle, label) {
177
+ if (!exists(rel)) return FAIL(`${rel} does not exist`)
178
+ const hit = typeof needle === 'string' ? read(rel).includes(needle) : needle.test(read(rel))
179
+ return hit ? FAIL(`${rel} still has ${needle}`) : PASS(label ?? `${rel} has no ${needle}`)
180
+ },
181
+
182
+ script(pkgRel, name) {
183
+ if (!exists(pkgRel)) return FAIL(`${pkgRel} does not exist`)
184
+ const cmd = (JSON.parse(read(pkgRel)) as { scripts?: Record<string, string> }).scripts?.[name]
185
+ return cmd ? PASS(`${pkgRel} runs "${name}": ${cmd}`) : FAIL(`${pkgRel} has no "${name}" script`)
186
+ },
187
+
188
+ async loads(rel) {
189
+ if (!exists(rel)) return FAIL(`${rel} does not exist`)
190
+ try {
191
+ const mod = (await import(pathToFileURL(abs(rel)).href)) as { default?: unknown }
192
+ return mod.default ? PASS(`${rel} loads`) : FAIL(`${rel} has no default export`)
193
+ } catch (e) {
194
+ return FAIL(`${rel} does not load: ${said(e).message.split('\n')[0]}`)
195
+ }
196
+ },
197
+
198
+ /** A workflow step that runs `npm run <script>`; a regex matches the script name. */
199
+ ci(script) {
200
+ const files = listFiles('.github/workflows', (n) => /\.ya?ml$/.test(n))
201
+ const name = typeof script === 'string' ? escape(script) : script.source
202
+ const step = new RegExp(`npm run (${name})(?![\\w:-])`)
203
+ for (const f of files) {
204
+ const m = read(f).match(step)
205
+ if (m) return PASS(`${f} runs npm run ${m[1]}`)
206
+ }
207
+ return FAIL(`no workflow runs npm run ${script}`)
208
+ },
209
+
210
+ /**
211
+ * A workflow job, by its id, with a step that runs `npm run <script>`. Branch
212
+ * protection requires a whole job by its name, so a required check needs the
213
+ * job, not the step somewhere (UIG-6). A job is a key two spaces in under
214
+ * `jobs:`, and ends at the next line that is not indented further; a comment
215
+ * line does not count.
216
+ */
217
+ ciJob(job, script) {
218
+ const files = listFiles('.github/workflows', (n) => /\.ya?ml$/.test(n))
219
+ const header = new RegExp(`^ ${escape(job)}:\\s*(#.*)?$`)
220
+ const step = new RegExp(`^(?!\\s*#).*npm run ${escape(script)}(?![\\w:-])`, 'm')
221
+ for (const f of files) {
222
+ const lines = read(f).split(/\r?\n/)
223
+ const from = lines.findIndex((l) => /^jobs:\s*$/.test(l))
224
+ const start = from === -1 ? -1 : lines.findIndex((l, i) => i > from && header.test(l))
225
+ if (start === -1) continue
226
+ const end = lines.findIndex((l, i) => i > start && /^ {0,2}\S/.test(l))
227
+ const body = lines.slice(start + 1, end === -1 ? undefined : end).join('\n')
228
+ return step.test(body) ? PASS(`${f}: the job ${job} runs npm run ${script}`) : FAIL(`${f}: the job ${job} does not run npm run ${script}`)
229
+ }
230
+ return FAIL(`no workflow has a job ${job}`)
231
+ },
232
+
233
+ hook(settingsRel, needle) {
234
+ if (!exists(settingsRel)) return FAIL(`${settingsRel} does not exist`)
235
+ let s: { hooks?: { PreToolUse?: { hooks?: { command?: string }[] }[] } }
236
+ try {
237
+ s = JSON.parse(read(settingsRel))
238
+ } catch {
239
+ return FAIL(`${settingsRel} is not valid JSON`)
240
+ }
241
+ const commands = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => x.command ?? ''))
242
+ const hit = commands.find((c) => c.includes(needle))
243
+ if (hit) return PASS(`${settingsRel} has a PreToolUse hook running ${needle}`)
244
+ return FAIL(commands.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`)
245
+ },
246
+
247
+ json(rel, test = () => true, label) {
248
+ if (!exists(rel)) return FAIL(`${rel} does not exist`)
249
+ let data: unknown
250
+ try {
251
+ data = JSON.parse(read(rel))
252
+ } catch {
253
+ return FAIL(`${rel} is not valid JSON`)
254
+ }
255
+ if (!git('ls-files', '--error-unmatch', rel)) return FAIL(`${rel} is not committed`)
256
+ return test(data) ? PASS(label ?? `${rel} is committed and parses`) : FAIL(`${rel} parses, but ${label ?? 'fails its test'}`)
257
+ },
258
+
259
+ /** Lints a line of code that is never written to disk, the way the hook will. */
260
+ async lint({ cwd = '.', config, file, code, expect, mentions }) {
261
+ const cfg = join(cwd, config)
262
+ if (!exists(cfg)) return FAIL(`${cfg} does not exist`)
263
+ let ESLint: typeof ESLintClass
264
+ try {
265
+ const req = createRequire(join(abs(cwd), 'package.json'))
266
+ ;({ ESLint } = (await import(pathToFileURL(req.resolve('eslint')).href)) as { ESLint: typeof ESLintClass })
267
+ } catch (e) {
268
+ return UNKNOWN(`could not load eslint from ${cwd}: ${said(e).message.split('\n')[0]}`)
269
+ }
270
+ let results: ESLintClass.LintResult[]
271
+ try {
272
+ const eslint = new ESLint({ cwd: abs(cwd), overrideConfigFile: config })
273
+ results = await eslint.lintText(code, { filePath: join(abs(cwd), file), warnIgnored: true })
274
+ } catch (e) {
275
+ return UNKNOWN(`${cfg} could not lint ${file}: ${said(e).message.split('\n')[0]}`)
276
+ }
277
+ const messages = results.flatMap((r) => r.messages)
278
+ const ignored = messages.some((m) => /ignored/i.test(m.message) && !m.ruleId)
279
+ const fatal = messages.find((m) => m.fatal && !/ignored/i.test(m.message))
280
+ if (fatal) return UNKNOWN(`${file} did not parse: ${fatal.message}`)
281
+ const want = expect === 'error' ? 2 : expect === 'warning' ? 1 : 0
282
+ if (want === 0) {
283
+ const any = messages.filter((m) => m.ruleId && m.severity === 2)
284
+ return any.length ? FAIL(`${file} gets an error it should not: ${any[0].message}`) : PASS(`${file}: no error${ignored ? ' (not linted)' : ''}`)
285
+ }
286
+ if (ignored) return FAIL(`${cfg} does not lint ${file}`)
287
+ const hit = messages.find((m) => m.ruleId && m.severity === want && (!mentions || m.message.includes(mentions)))
288
+ const kind = want === 2 ? 'error' : 'warning'
289
+ return hit ? PASS(`${file} gets ${want === 2 ? 'an' : 'a'} ${kind}: ${hit.message}`) : FAIL(`${file} gets no ${kind}${mentions ? ` naming ${mentions}` : ''}`)
290
+ },
291
+
292
+ /**
293
+ * A GitHub setting, not a file, so it is asked of GitHub: the checks a merge
294
+ * into the default branch must pass, from the rulesets in force on it
295
+ * (`rules/branches/<branch>`, which anyone who can read the repository may
296
+ * see) and from classic protection as `branches/<branch>` reports it. Not
297
+ * `branches/<branch>/protection`: GitHub answers "Not Found" there to anyone
298
+ * who is not an admin, and it knows nothing of rulesets (UIG-6).
299
+ */
300
+ protectedBranch(pattern) {
301
+ const url = git('remote', 'get-url', 'origin')
302
+ const slug = url?.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1]
303
+ if (!slug) return UNKNOWN('no GitHub remote')
304
+ const branch = (git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? 'origin/main').replace(/^origin\//, '')
305
+ const ask = (path: string) => JSON.parse(execFileSync('gh', ['api', path], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 }).toString())
306
+ let rules: { type: string; parameters?: { required_status_checks?: { context: string }[] } }[]
307
+ let classic: { contexts?: string[]; checks?: { context: string }[] } | undefined
308
+ try {
309
+ rules = ask(`repos/${slug}/rules/branches/${branch}`)
310
+ classic = ask(`repos/${slug}/branches/${branch}`).protection?.required_status_checks
311
+ } catch (e) {
312
+ const s = said(e)
313
+ return UNKNOWN(`could not ask GitHub (${s.code === 'ENOENT' ? 'gh is not installed' : s.text.split('\n')[0] || s.message})`)
314
+ }
315
+ const names = [
316
+ ...rules.filter((r) => r.type === 'required_status_checks').flatMap((r) => r.parameters?.required_status_checks ?? []).map((c) => c.context),
317
+ ...(classic?.contexts ?? []),
318
+ ...(classic?.checks ?? []).map((c) => c.context),
319
+ ]
320
+ const hit = names.find((n) => pattern.test(n))
321
+ if (hit) return PASS(`${slug} ${branch} requires "${hit}"`)
322
+ return FAIL(names.length ? `${slug} ${branch} requires ${names.map((n) => `"${n}"`).join(', ')}, none matching ${pattern}` : `${slug} ${branch} requires no check to merge`)
323
+ },
324
+
325
+ gh(args, label) {
326
+ try {
327
+ execFileSync('gh', args, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 })
328
+ return PASS(label)
329
+ } catch (e) {
330
+ const s = said(e)
331
+ if (/could not resolve|not found|HTTP 404/i.test(s.text)) return FAIL(`not yet: ${label}`)
332
+ return UNKNOWN(`could not ask GitHub (${s.code === 'ENOENT' ? 'gh is not installed' : s.text.split('\n')[0] || s.message})`)
333
+ }
334
+ },
335
+
336
+ /** How many of `files` pass `test`: all is a pass, some is a part. */
337
+ share(files, test, label) {
338
+ if (!files.length) return FAIL(`no files to check for: ${label}`)
339
+ const ok = files.filter(test).length
340
+ const detail = `${ok} of ${files.length}: ${label}`
341
+ return ok === files.length ? PASS(detail) : ok ? PART(detail) : FAIL(detail)
342
+ },
343
+ }
344
+ }
345
+
346
+ // ── running the checks ─────────────────────────────────────────────────────
347
+
348
+ const STATUS: Record<string, string> = { done: '✅', started: '🚧', none: '⬜', unknown: '❔' }
349
+
350
+ function statusOf(checks: CheckResult[]) {
351
+ const n = (r: CheckResult['result']) => checks.filter((c) => c.result === r).length
352
+ const pass = n('pass'), part = n('part'), fail = n('fail'), unknown = n('unknown')
353
+ if (!checks.length) return 'unknown'
354
+ if (pass === checks.length) return 'done'
355
+ if (pass + part > 0) return fail === 0 && unknown > 0 ? 'unknown' : 'started'
356
+ return fail > 0 ? 'none' : 'unknown'
357
+ }
358
+
359
+ async function runRepo(ROOT: string, h: GateHelpers): Promise<{ spec: GateSpec; report: Report }> {
360
+ const checksFile = join(ROOT, 'scripts', 'gates-checks.mjs')
361
+ const define = ((await import(pathToFileURL(checksFile).href)) as { default: (h: GateHelpers) => GateSpec }).default
362
+ const spec = define(h)
363
+ const git = (...a: string[]) => {
364
+ try {
365
+ return execFileSync('git', a, { cwd: ROOT, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
366
+ } catch {
367
+ return null
368
+ }
369
+ }
370
+ const rows: Row[] = []
371
+ for (const t of spec.tickets) {
372
+ const checks: Row['checks'] = []
373
+ for (const c of t.checks ?? []) {
374
+ if (c.aggregate) continue
375
+ let r: CheckResult
376
+ try {
377
+ r = await c.run()
378
+ } catch (e) {
379
+ r = UNKNOWN(`the check threw: ${said(e).message}`)
380
+ }
381
+ checks.push({ repo: spec.repo, what: c.what, ...r })
382
+ }
383
+ rows.push({ ref: t.ref, title: t.title, owner: t.owner, checks })
384
+ }
385
+ return { spec, report: { schema: 1, repo: spec.repo, branch: git('rev-parse', '--abbrev-ref', 'HEAD'), commit: git('rev-parse', '--short', 'HEAD'), engine: ENGINE, rows } }
386
+ }
387
+
388
+ /**
389
+ * Another repository's report. A repo on the package's engine runs it from its
390
+ * own install; a repo still carrying `scripts/gates-status.mjs` runs that copy.
391
+ */
392
+ function runSibling(dir: string, shown: string): { report?: Report; error?: string } {
393
+ const copy = join(dir, 'scripts', 'gates-status.mjs')
394
+ const installed = createRequire(join(dir, 'package.json'))
395
+ let script = copy
396
+ const args = ['--json']
397
+ if (!existsSync(copy)) {
398
+ try {
399
+ script = join(installed.resolve('@estiva-app/ui/package.json'), '..', 'dist', 'gates', 'cli.js')
400
+ args.unshift('status')
401
+ } catch {
402
+ return { error: `${shown} has neither its own gates-status.mjs nor @estiva-app/ui installed` }
403
+ }
404
+ if (!existsSync(script)) return { error: `${shown} has an @estiva-app/ui with no gates engine: install 0.21.0 or later` }
405
+ }
406
+ try {
407
+ const out = execFileSync(process.execPath, [script, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], timeout: 300000, maxBuffer: 16 * 1024 * 1024 })
408
+ return { report: JSON.parse(out.toString()) as Report }
409
+ } catch (e) {
410
+ const err = e as { stderr?: unknown; message?: string }
411
+ return { error: `its gates:status failed: ${String(err.stderr ?? err.message).split('\n').filter(Boolean).slice(-1)[0]}` }
412
+ }
413
+ }
414
+
415
+ function pad(s: string, n: number) {
416
+ const len = [...s].length
417
+ return len >= n ? s : s + ' '.repeat(n - len)
418
+ }
419
+
420
+ function printRows(rows: Row[], { showOwner, detail }: { showOwner: boolean; detail: boolean }) {
421
+ const lines: string[] = []
422
+ const width = Math.max(...rows.map((r) => [...(r.title ?? '')].length))
423
+ for (const r of rows) {
424
+ const pass = r.checks.filter((c) => c.result === 'pass').length
425
+ const count = r.checks.length ? `${pass} of ${r.checks.length}` : 'no checks here'
426
+ lines.push(`${STATUS[r.status ?? 'unknown']} ${pad(r.ref, 7)} ${pad(r.title ?? '', width)} ${showOwner ? pad(r.ownerRepo ?? '', 10) : ''}${count}`)
427
+ if (detail || r.status === 'started' || r.status === 'unknown') {
428
+ for (const c of r.checks) {
429
+ const mark = { pass: '✓', part: '½', fail: '✗', unknown: '?' }[c.result]
430
+ lines.push(` ${mark} ${showOwner ? `${pad(c.repo, 10)} ` : ''}${c.what} — ${c.detail}`)
431
+ }
432
+ }
433
+ }
434
+ return lines.join('\n')
435
+ }
436
+
437
+ function summary(rows: Row[]) {
438
+ const n = (s: string) => rows.filter((r) => r.status === s).length
439
+ return `${STATUS.done} ${n('done')} done · ${STATUS.started} ${n('started')} started · ${STATUS.none} ${n('none')} not started · ${STATUS.unknown} ${n('unknown')} could not check`
440
+ }
441
+
442
+ export interface StatusOptions {
443
+ /** The repository to report on. */
444
+ root?: string
445
+ json?: boolean
446
+ detail?: boolean
447
+ }
448
+
449
+ /** Print where the project stands. Returns what was printed, or the JSON report. */
450
+ export async function runStatus({ root = process.cwd(), json = false, detail = false }: StatusOptions = {}): Promise<string> {
451
+ const ROOT = resolve(root)
452
+ const h = helpers(ROOT)
453
+ const shown = (dir: string) => (relative(ROOT, dir) || dir).replace(/\\/g, '/')
454
+ const { spec, report } = await runRepo(ROOT, h)
455
+
456
+ if (json) return JSON.stringify(report)
457
+
458
+ const out: string[] = []
459
+ out.push('UI Guardrails · gates:status', 'Read from the code. Nothing here is a hand-ticked list.', '')
460
+ out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}`)
461
+
462
+ if (!spec.all) {
463
+ // A sibling repo on its own: its own rows, then its parts of rows owned elsewhere.
464
+ const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }))
465
+ const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: '', status: statusOf(r.checks) }))
466
+ out.push('', `Tickets ${spec.repo} owns (${own.length}):`, printRows(own, { showOwner: false, detail }))
467
+ out.push('', `Parts of tickets another repo owns, checked here (${parts.length}):`, printRows(parts, { showOwner: false, detail }))
468
+ out.push('', 'For every ticket across the repos, run npm run gates:status in estiva-ui.')
469
+ return out.join('\n')
470
+ }
471
+
472
+ // estiva-ui: gather the sibling checkouts and join everything into one row per ticket.
473
+ const reports: Report[] = [report]
474
+ const found: { name: string; note: string; missing?: boolean }[] = []
475
+ for (const s of spec.siblings ?? []) {
476
+ const dir = resolve(ROOT, process.env[s.env] ?? s.path)
477
+ if (!existsSync(join(dir, 'package.json'))) {
478
+ found.push({ name: s.name, note: `not found at ${shown(dir)} (set ${s.env} to point at it)`, missing: true })
479
+ continue
480
+ }
481
+ const r = runSibling(dir, shown(dir))
482
+ if (!r.report) {
483
+ found.push({ name: s.name, note: r.error ?? 'no report', missing: true })
484
+ continue
485
+ }
486
+ 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') : ''
487
+ found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}` })
488
+ reports.push(r.report)
489
+ }
490
+ for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`)
491
+
492
+ const problems: string[] = []
493
+ const known = new Set(spec.all.map((t) => t.ref))
494
+ for (const rep of reports) {
495
+ for (const r of rep.rows.filter((r) => !known.has(r.ref))) problems.push(`${rep.repo} reports ${r.ref}, which is not one of the tickets`)
496
+ }
497
+ const rows: Row[] = spec.all.map((t) => {
498
+ const pieces = reports.flatMap((rep) => rep.rows.filter((r) => r.ref === t.ref).map((r) => ({ rep, r })))
499
+ const claims = pieces.filter((p) => p.r.owner).map((p) => p.rep.repo)
500
+ const ownerMissing = found.some((f) => f.name === t.owner && f.missing)
501
+ if (!ownerMissing && (claims.length !== 1 || claims[0] !== t.owner)) {
502
+ problems.push(`${t.ref}: should be owned by ${t.owner}, is claimed by ${claims.join(' and ') || 'no repo'}`)
503
+ }
504
+ for (const p of pieces.filter((p) => !p.r.owner && !(t.parts ?? []).includes(p.rep.repo))) {
505
+ problems.push(`${t.ref}: ${p.rep.repo} checks a part of it, but the ticket list does not name ${p.rep.repo}`)
506
+ }
507
+ const checks = pieces.flatMap((p) => p.r.checks)
508
+ for (const f of found.filter((f) => f.missing)) {
509
+ if ((t.parts ?? []).includes(f.name) || t.owner === f.name) checks.push({ repo: f.name, what: `${f.name}'s part`, result: 'unknown', detail: f.note })
510
+ }
511
+ return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate }
512
+ })
513
+ for (const row of rows) row.status = statusOf(row.checks)
514
+ for (const row of rows.filter((r) => r.aggregate)) {
515
+ const others = rows.filter((r) => r !== row)
516
+ const done = others.filter((r) => r.status === 'done').length
517
+ row.checks.push({ repo: 'all', what: 'every other ticket is done', ...(done === others.length ? PASS(`${done} of ${others.length} done`) : FAIL(`${done} of ${others.length} done`)) })
518
+ row.status = statusOf(row.checks)
519
+ }
520
+
521
+ out.push('', printRows(rows, { showOwner: true, detail }), '', summary(rows))
522
+ const owners = Object.entries(rows.reduce<Record<string, number>>((a, r) => ({ ...a, [r.ownerRepo ?? '']: (a[r.ownerRepo ?? ''] ?? 0) + 1 }), {}))
523
+ .map(([k, v]) => `${k} ${v}`)
524
+ .join(', ')
525
+ out.push(`${rows.length} tickets. Owned by ${owners}.`)
526
+ const unchecked = found.filter((f) => f.missing && spec.all!.some((t) => t.owner === f.name)).map((f) => f.name)
527
+ if (problems.length) out.push(`⚠️ Ownership does not add up:\n ${problems.join('\n ')}`)
528
+ else if (unchecked.length) out.push(`❔ Ownership not fully checked: ${unchecked.join(' and ')} could not be read.`)
529
+ else out.push('✅ Each ticket is owned by exactly one repo, and every repo agrees.')
530
+ if (!detail) out.push('Add -- --detail to see every check.')
531
+ return out.join('\n')
532
+ }