@estiva-app/ui 0.35.2 → 0.37.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 (143) hide show
  1. package/dist/Panel.d.ts +44 -0
  2. package/dist/Panel.d.ts.map +1 -0
  3. package/dist/ScrollArea.d.ts +7 -2
  4. package/dist/ScrollArea.d.ts.map +1 -1
  5. package/dist/eslint/escape.d.ts +23 -6
  6. package/dist/eslint/escape.d.ts.map +1 -1
  7. package/dist/eslint/index.js +86 -12
  8. package/dist/eslint/index.js.map +3 -3
  9. package/dist/eslint/no-handmade-header.d.ts +5 -0
  10. package/dist/eslint/no-handmade-header.d.ts.map +1 -1
  11. package/dist/eslint/no-native-title.d.ts +1 -1
  12. package/dist/eslint/no-native-title.d.ts.map +1 -1
  13. package/dist/eslint/no-raw-element.d.ts +7 -0
  14. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  15. package/dist/eslint/no-restyled-part.d.ts +8 -0
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/gates/app-checks.d.ts +1 -1
  18. package/dist/gates/app-checks.d.ts.map +1 -1
  19. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  20. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  21. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  22. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  23. package/dist/gates/ci.d.ts +18 -0
  24. package/dist/gates/ci.d.ts.map +1 -0
  25. package/dist/gates/cli.js +20 -3
  26. package/dist/gates/cli.js.map +2 -2
  27. package/dist/gates/create-app.d.ts.map +1 -1
  28. package/dist/gates/create-app.js +1 -1
  29. package/dist/gates/escapes.d.ts +23 -0
  30. package/dist/gates/escapes.d.ts.map +1 -0
  31. package/dist/gates/gate-config.d.ts.map +1 -1
  32. package/dist/gates/hook.d.ts +2 -0
  33. package/dist/gates/hook.d.ts.map +1 -1
  34. package/dist/gates/index.d.ts +5 -1
  35. package/dist/gates/index.d.ts.map +1 -1
  36. package/dist/gates/index.js +42 -185
  37. package/dist/gates/index.js.map +4 -4
  38. package/dist/gates/name-escapes.d.ts +14 -0
  39. package/dist/gates/name-escapes.d.ts.map +1 -0
  40. package/dist/gates/status.d.ts +3 -1
  41. package/dist/gates/status.d.ts.map +1 -1
  42. package/dist/gates/token-lint.d.ts.map +1 -1
  43. package/dist/gates/token-style.d.ts +21 -0
  44. package/dist/gates/token-style.d.ts.map +1 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +76 -49
  48. package/dist/index.js.map +4 -4
  49. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  50. package/dist/registry/app.d.ts.map +1 -1
  51. package/dist/registry/behaviours.d.ts +71 -0
  52. package/dist/registry/behaviours.d.ts.map +1 -0
  53. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  54. package/dist/registry/build.d.ts.map +1 -1
  55. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  56. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  57. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  58. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  59. package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  60. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  61. package/dist/registry/cli.js +7 -6
  62. package/dist/registry/cli.js.map +2 -2
  63. package/dist/registry/contract.d.ts +13 -1
  64. package/dist/registry/contract.d.ts.map +1 -1
  65. package/dist/registry/index.d.ts +1 -1
  66. package/dist/registry/index.d.ts.map +1 -1
  67. package/dist/registry/index.js +11 -5
  68. package/package.json +4 -1
  69. package/registry.json +369 -19
  70. package/skill/estiva-ui.md +2 -1
  71. package/src/AppShell.mdx +2 -1
  72. package/src/AttachmentCard.tsx +7 -7
  73. package/src/Button.tsx +1 -1
  74. package/src/Card.tsx +2 -2
  75. package/src/ChipInput.mdx +2 -0
  76. package/src/ChipInput.tsx +1 -1
  77. package/src/CommandPalette.tsx +3 -3
  78. package/src/DialogShell.tsx +1 -1
  79. package/src/EmptyState.mdx +4 -3
  80. package/src/Lightbox.tsx +1 -1
  81. package/src/NavItem.tsx +1 -1
  82. package/src/Panel.mdx +78 -0
  83. package/src/Panel.stories.tsx +98 -0
  84. package/src/Panel.tsx +80 -0
  85. package/src/PersonTrigger.tsx +1 -1
  86. package/src/Reaction.tsx +1 -1
  87. package/src/ScrollArea.mdx +6 -2
  88. package/src/ScrollArea.stories.tsx +1 -1
  89. package/src/ScrollArea.tsx +9 -4
  90. package/src/Select.tsx +1 -1
  91. package/src/TopBar.mdx +1 -1
  92. package/src/eslint/escape.ts +43 -14
  93. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  94. package/src/eslint/index.test.ts +2 -2
  95. package/src/eslint/no-copied-look.test.ts +1 -1
  96. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  97. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  98. package/src/eslint/no-handmade-header.test.ts +11 -2
  99. package/src/eslint/no-handmade-header.ts +24 -2
  100. package/src/eslint/no-native-title.test.ts +2 -2
  101. package/src/eslint/no-native-title.ts +1 -1
  102. package/src/eslint/no-raw-element.test.ts +47 -15
  103. package/src/eslint/no-raw-element.ts +53 -3
  104. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  105. package/src/eslint/no-restyled-part.test.ts +21 -2
  106. package/src/eslint/no-restyled-part.ts +30 -0
  107. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  108. package/src/gates/app-checks.ts +9 -6
  109. package/src/gates/ci.ts +152 -0
  110. package/src/gates/cli.ts +19 -3
  111. package/src/gates/count.ts +1 -1
  112. package/src/gates/create-app.test.ts +2 -2
  113. package/src/gates/create-app.ts +22 -16
  114. package/src/gates/escapes.ts +111 -0
  115. package/src/gates/gate-config.ts +2 -0
  116. package/src/gates/gates.test.ts +86 -1
  117. package/src/gates/hook.ts +38 -4
  118. package/src/gates/index.ts +5 -1
  119. package/src/gates/name-escapes.ts +77 -0
  120. package/src/gates/status.ts +45 -2
  121. package/src/gates/token-lint.ts +11 -4
  122. package/src/gates/token-style.ts +79 -0
  123. package/src/how.test.ts +282 -0
  124. package/src/index.ts +1 -0
  125. package/src/pages.test.ts +6 -0
  126. package/src/registry/app.test.ts +37 -1
  127. package/src/registry/app.ts +52 -2
  128. package/src/registry/behaviours.ts +223 -0
  129. package/src/registry/build.ts +36 -0
  130. package/src/registry/cli.ts +5 -3
  131. package/src/registry/contract.test.ts +27 -2
  132. package/src/registry/contract.ts +28 -3
  133. package/src/registry/index.ts +1 -1
  134. package/src/registry/registry.test.ts +18 -4
  135. package/stories/Choosing.mdx +4 -3
  136. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  137. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  138. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  139. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  140. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  141. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  142. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  143. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -1,607 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- repositoryOf
4
- } from "./chunk-YKZXKVR2.js";
5
-
6
- // src/gates/count.ts
7
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
- import { createRequire } from "node:module";
9
- import { join, relative } from "node:path";
10
- import { pathToFileURL } from "node:url";
11
- import { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS } from "../eslint/index.js";
12
- async function eslintFrom(root) {
13
- const resolved = createRequire(join(root, "package.json")).resolve("eslint");
14
- return (await import(pathToFileURL(resolved).href)).ESLint;
15
- }
16
- async function writeGateCount({ root = process.cwd(), repo, audience = "app", config = "eslint.gates.config.js" }) {
17
- const out = join(root, ".gates-count.json");
18
- const ESLint = await eslintFrom(root);
19
- const eslint = new ESLint({
20
- cwd: root,
21
- overrideConfigFile: join(root, config),
22
- overrideConfig: { settings: { estiva: { reportEscapes: true } } }
23
- });
24
- const { rules, disabled } = countGates(await eslint.lintFiles(["."]), audience === "package" ? PACKAGE_RULE_IDS : APP_RULE_IDS);
25
- const previous = existsSync(out) ? JSON.parse(readFileSync(out, "utf8")) : null;
26
- const changed = JSON.stringify(previous?.rules) !== JSON.stringify(rules);
27
- if (changed) {
28
- const count = { schemaVersion: 1, repo, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), rules };
29
- writeFileSync(out, `${JSON.stringify(count, null, 2)}
30
- `);
31
- }
32
- const lines = [changed ? ".gates-count.json: written" : ".gates-count.json: unchanged"];
33
- for (const [id, c] of Object.entries(rules)) lines.push(` ${id}: ${c.errors} errors, ${c.warnings} warnings, ${c.escapes} escapes`);
34
- const failures = [];
35
- if (disabled.length > 0) {
36
- failures.push("", "An eslint-disable switched a gate rule off. Write the reason above the element instead: // @estiva-escape: <reason>");
37
- for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`);
38
- }
39
- return { rules, disabled, changed, lines, failures };
40
- }
41
-
42
- // src/gates/hook.ts
43
- import { spawnSync } from "node:child_process";
44
- import { createHash } from "node:crypto";
45
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
46
- import { createRequire as createRequire2 } from "node:module";
47
- import { tmpdir } from "node:os";
48
- import { basename, isAbsolute, join as join2, relative as relative2, resolve, sep } from "node:path";
49
- import { fileURLToPath, pathToFileURL as pathToFileURL2 } from "node:url";
50
- import { countGates as countGates2 } from "../eslint/index.js";
51
- var PASS = { code: 0, message: "" };
52
- var APP_SOURCE = /^src\/.+\.[cm]?[jt]sx?$/;
53
- async function runHook({ root, app = ".", audience = "app", input, seen } = {}) {
54
- let call;
55
- try {
56
- call = input ?? JSON.parse(readFileSync2(0, "utf8"));
57
- } catch {
58
- return PASS;
59
- }
60
- const args = call.tool_input ?? {};
61
- if (typeof args.file_path !== "string") return PASS;
62
- const top = root ?? process.env.CLAUDE_PROJECT_DIR ?? call.cwd ?? process.cwd();
63
- const appDir = resolve(top, app);
64
- const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path);
65
- const rel = relative2(appDir, file).split(sep).join("/");
66
- const source = audience === "package" ? /^src\/.+\.tsx$/ : APP_SOURCE;
67
- if (rel.startsWith("../") || isAbsolute(rel) || !source.test(rel) || /\.test\.[cm]?[jt]sx?$/.test(rel) || /\.d\.[cm]?ts$/.test(rel)) return PASS;
68
- const text = proposed(call.tool_name, args, file);
69
- if (text === null) return PASS;
70
- const resolved = createRequire2(join2(appDir, "package.json")).resolve("eslint");
71
- const { ESLint } = await import(pathToFileURL2(resolved).href);
72
- const config = join2(appDir, "eslint.gates.config.js");
73
- const eslint = new ESLint({ cwd: appDir, overrideConfigFile: config });
74
- const results = await eslint.lintText(text, { filePath: file });
75
- const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId));
76
- const { disabled } = countGates2(results);
77
- if (errors.length === 0 && disabled.length === 0) return searchFirst(call, file, text, appDir, top, seen) ?? PASS;
78
- const shown = (p) => relative2(top, p).split(sep).join("/");
79
- const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`];
80
- for (const m of errors) lines.push(` ${m.line}:${m.column} ${m.message}${m.ruleId ? ` (${m.ruleId})` : ""}`);
81
- const keep = audience === "package" ? "Keep it only" : "Keep the element only";
82
- for (const d of disabled) lines.push(` ${d.line} an eslint-disable switches ${d.ruleId} off. ${keep} with its reason on the line above: // @estiva-escape: <reason>`);
83
- return { code: 2, message: `${lines.join("\n")}
84
- ` };
85
- }
86
- function newPart(tool, file, text) {
87
- if (tool !== "Write" || existsSync2(file) || !file.endsWith(".tsx") || /\.stories\.tsx$/.test(file)) return null;
88
- if (!/<\/[A-Za-z]|\/>/.test(text)) return null;
89
- const named = /export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class)\s+([A-Z][A-Za-z0-9]*)/.exec(text)?.[1];
90
- if (named) return named;
91
- return /export\s+default\s+function\s*\(/.test(text) ? basename(file, ".tsx") : null;
92
- }
93
- var wordsOf = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").toLowerCase();
94
- function findCommand() {
95
- for (const at of ["../registry/cli.js", "../../dist/registry/cli.js"]) {
96
- const path = fileURLToPath(new URL(at, import.meta.url));
97
- if (existsSync2(path)) return path;
98
- }
99
- return null;
100
- }
101
- function searchFirst(call, file, text, appDir, top, seen) {
102
- const part = newPart(call.tool_name, file, text);
103
- const command = findCommand();
104
- if (!part || !command) return null;
105
- const folder = seen ?? join2(tmpdir(), "estiva-ui-search-first");
106
- const mark = join2(folder, createHash("sha1").update(`${call.session_id ?? ""}
107
- ${resolve(file)}`).digest("hex"));
108
- if (existsSync2(mark)) return null;
109
- const words = wordsOf(part);
110
- const repo = basename(repositoryOf(appDir));
111
- const run = spawnSync(process.execPath, [command, "find", ...words.split(" "), "--root", appDir, "--repo", repo, "--limit", "5"], { encoding: "utf8", timeout: 6e4 });
112
- if (run.status !== 0 || !run.stdout.trim()) return null;
113
- try {
114
- mkdirSync(folder, { recursive: true });
115
- writeFileSync2(mark, "");
116
- } catch {
117
- return null;
118
- }
119
- const shown = relative2(top, file).split(sep).join("/");
120
- return {
121
- code: 2,
122
- message: `${shown} was not written yet: it adds a new part, ${part}. Search before building (UIG-20). What already exists for "${words}":
123
-
124
- ${run.stdout.trim()}
125
-
126
- If one of these does the job, use it, and read its page first. If the words were wrong, run \`npm run ui:find <other words>\`. If nothing fits, tell the person, then write the file again: it goes through.
127
- `
128
- };
129
- }
130
- function proposed(tool, args, file) {
131
- if (tool === "Write") return typeof args.content === "string" ? args.content : null;
132
- if (tool !== "Edit" || typeof args.old_string !== "string" || typeof args.new_string !== "string") return null;
133
- const { old_string: from, new_string: to } = args;
134
- const current = existsSync2(file) ? readFileSync2(file, "utf8") : "";
135
- for (const text of [current, current.replace(/\r\n/g, "\n")]) {
136
- if (!text.includes(from)) continue;
137
- return args.replace_all === true ? text.split(from).join(to) : text.replace(from, () => to);
138
- }
139
- return null;
140
- }
141
-
142
- // src/gates/status.ts
143
- import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
144
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
145
- import { createRequire as createRequire3 } from "node:module";
146
- import { join as join3, relative as relative3, resolve as resolve2 } from "node:path";
147
- import { pathToFileURL as pathToFileURL3 } from "node:url";
148
- var { version } = createRequire3(import.meta.url)("../../package.json");
149
- var ENGINE = `@estiva-app/ui@${version}`;
150
- var PASS2 = (detail) => ({ result: "pass", detail });
151
- var FAIL = (detail) => ({ result: "fail", detail });
152
- var PART = (detail) => ({ result: "part", detail });
153
- var UNKNOWN = (detail) => ({ result: "unknown", detail });
154
- var said = (e) => {
155
- const err = e;
156
- return { text: `${err.stdout ?? ""}${err.stderr ?? ""}`, message: String(err.message ?? e), code: err.code };
157
- };
158
- function helpers(ROOT) {
159
- const abs = (rel) => resolve2(ROOT, rel);
160
- const exists = (rel) => existsSync3(abs(rel));
161
- const read = (rel) => readFileSync3(abs(rel), "utf8");
162
- const git = (...a) => {
163
- try {
164
- return execFileSync("git", a, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
165
- } catch {
166
- return null;
167
- }
168
- };
169
- const listFiles = (rel, test) => {
170
- const out = [];
171
- const walk = (dir) => {
172
- if (!existsSync3(dir)) return;
173
- for (const name of readdirSync(dir)) {
174
- if (["node_modules", ".git", "dist", "storybook-static", ".verify-shots"].includes(name)) continue;
175
- const p = join3(dir, name);
176
- if (statSync(p).isDirectory()) walk(p);
177
- else if (test(name, p)) out.push(relative3(ROOT, p).replace(/\\/g, "/"));
178
- }
179
- };
180
- walk(abs(rel));
181
- return out;
182
- };
183
- const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
184
- return {
185
- PASS: PASS2,
186
- FAIL,
187
- PART,
188
- UNKNOWN,
189
- exists,
190
- read,
191
- listFiles,
192
- // The registry bundle's own builder, loaded only when a check asks for it:
193
- // it reads TypeScript with TypeScript, which the other checks never need.
194
- async catalogue(appDir) {
195
- try {
196
- const { buildAppRegistry, validateRegistry } = await import("../registry/index.js");
197
- const registry = buildAppRegistry({ root: abs(appDir) });
198
- const problems = validateRegistry(registry);
199
- if (problems.length) return FAIL(`${problems.length} problems, the first: ${problems[0]}`);
200
- return PASS2(`${registry.entries.length} parts in ${registry.builtFrom.files} files, each described and sorted`);
201
- } catch (error) {
202
- const lines = String(error instanceof Error ? error.message : error).split("\n");
203
- return FAIL(lines.length > 1 ? `${lines.length - 1} problems, the first: ${lines[1].trim()}` : lines[0]);
204
- }
205
- },
206
- async contract(appDir) {
207
- try {
208
- const { buildAppRegistry, contractProblems, CONTRACT_KINDS } = await import("../registry/index.js");
209
- const registry = buildAppRegistry({ root: abs(appDir) });
210
- const problems = contractProblems(registry, abs(appDir));
211
- const owed = registry.entries.filter((entry) => CONTRACT_KINDS.includes(entry.app?.class ?? "")).length;
212
- if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`);
213
- return PASS2(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`);
214
- } catch (error) {
215
- return FAIL(String(error instanceof Error ? error.message : error).split("\n")[0]);
216
- }
217
- },
218
- instructions(limit = 200) {
219
- if (!exists("CLAUDE.md")) return FAIL("there is no CLAUDE.md");
220
- const lines = (rel) => read(rel).replace(/\r?\n$/, "").split(/\r?\n/).length;
221
- const imports = [...read("CLAUDE.md").matchAll(/^@(\S+)\s*$/gm)].map((m) => m[1]);
222
- const missing = imports.filter((rel) => !exists(rel));
223
- if (missing.length) return FAIL(`CLAUDE.md imports ${missing.join(", ")}, which is not there`);
224
- const total = imports.reduce((n, rel) => n + lines(rel), lines("CLAUDE.md"));
225
- const what = imports.length ? `CLAUDE.md with ${imports.join(", ")}` : "CLAUDE.md";
226
- if (total >= limit) return FAIL(`${what}: ${total} lines load into every session; the limit is ${limit}`);
227
- const rules = listFiles(".claude/rules", (n) => n.endsWith(".md"));
228
- const unscoped = rules.filter((rel) => {
229
- const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(read(rel));
230
- return !front || !/^paths:/m.test(front[1]);
231
- });
232
- if (unscoped.length) return FAIL(`${unscoped.join(", ")} has no paths:, so it loads into every session`);
233
- return PASS2(`${what}: ${total} lines in every session; ${rules.length ? `${rules.length} rule files, each loading only for its paths` : "no rule files"}`);
234
- },
235
- file(rel) {
236
- return exists(rel) ? PASS2(`${rel} exists`) : FAIL(`${rel} does not exist`);
237
- },
238
- committed(rel) {
239
- if (!exists(rel)) return FAIL(`${rel} does not exist`);
240
- return git("ls-files", "--error-unmatch", rel) ? PASS2(`${rel} is committed`) : FAIL(`${rel} exists but is not committed`);
241
- },
242
- contains(rel, needle, label) {
243
- if (!exists(rel)) return FAIL(`${rel} does not exist`);
244
- const hit = typeof needle === "string" ? read(rel).includes(needle) : needle.test(read(rel));
245
- return hit ? PASS2(label ?? `${rel} has ${needle}`) : FAIL(label ? `not yet: ${label}` : `${rel} has no ${needle}`);
246
- },
247
- lacks(rel, needle, label) {
248
- if (!exists(rel)) return FAIL(`${rel} does not exist`);
249
- const hit = typeof needle === "string" ? read(rel).includes(needle) : needle.test(read(rel));
250
- return hit ? FAIL(`${rel} still has ${needle}`) : PASS2(label ?? `${rel} has no ${needle}`);
251
- },
252
- script(pkgRel, name) {
253
- if (!exists(pkgRel)) return FAIL(`${pkgRel} does not exist`);
254
- const cmd = JSON.parse(read(pkgRel)).scripts?.[name];
255
- return cmd ? PASS2(`${pkgRel} runs "${name}": ${cmd}`) : FAIL(`${pkgRel} has no "${name}" script`);
256
- },
257
- async loads(rel) {
258
- if (!exists(rel)) return FAIL(`${rel} does not exist`);
259
- try {
260
- const mod = await import(pathToFileURL3(abs(rel)).href);
261
- return mod.default ? PASS2(`${rel} loads`) : FAIL(`${rel} has no default export`);
262
- } catch (e) {
263
- return FAIL(`${rel} does not load: ${said(e).message.split("\n")[0]}`);
264
- }
265
- },
266
- /** A workflow step that runs `npm run <script>`; a regex matches the script name. */
267
- ci(script) {
268
- const files = listFiles(".github/workflows", (n) => /\.ya?ml$/.test(n));
269
- const name = typeof script === "string" ? escape(script) : script.source;
270
- const step = new RegExp(`npm run (${name})(?![\\w:-])`);
271
- for (const f of files) {
272
- const m = read(f).match(step);
273
- if (m) return PASS2(`${f} runs npm run ${m[1]}`);
274
- }
275
- return FAIL(`no workflow runs npm run ${script}`);
276
- },
277
- /**
278
- * A workflow job, by its id, with a step that runs `npm run <script>`. Branch
279
- * protection requires a whole job by its name, so a required check needs the
280
- * job, not the step somewhere (UIG-6). A job is a key two spaces in under
281
- * `jobs:`, and ends at the next line that is not indented further; a comment
282
- * line does not count.
283
- */
284
- ciJob(job, script) {
285
- const files = listFiles(".github/workflows", (n) => /\.ya?ml$/.test(n));
286
- const header = new RegExp(`^ ${escape(job)}:\\s*(#.*)?$`);
287
- const step = new RegExp(`^(?!\\s*#).*npm run ${escape(script)}(?![\\w:-])`, "m");
288
- for (const f of files) {
289
- const lines = read(f).split(/\r?\n/);
290
- const from = lines.findIndex((l) => /^jobs:\s*$/.test(l));
291
- const start = from === -1 ? -1 : lines.findIndex((l, i) => i > from && header.test(l));
292
- if (start === -1) continue;
293
- const end = lines.findIndex((l, i) => i > start && /^ {0,2}\S/.test(l));
294
- const body = lines.slice(start + 1, end === -1 ? void 0 : end).join("\n");
295
- return step.test(body) ? PASS2(`${f}: the job ${job} runs npm run ${script}`) : FAIL(`${f}: the job ${job} does not run npm run ${script}`);
296
- }
297
- return FAIL(`no workflow has a job ${job}`);
298
- },
299
- hook(settingsRel, needle) {
300
- if (!exists(settingsRel)) return FAIL(`${settingsRel} does not exist`);
301
- let s;
302
- try {
303
- s = JSON.parse(read(settingsRel));
304
- } catch {
305
- return FAIL(`${settingsRel} is not valid JSON`);
306
- }
307
- const entries = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => ({ matcher: m.matcher ?? "", command: x.command ?? "" })));
308
- const hit = entries.find((e) => e.command.includes(needle));
309
- if (!hit) return FAIL(entries.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`);
310
- let matches;
311
- try {
312
- matches = hit.matcher === "" || hit.matcher === "*" || new RegExp(`^(?:${hit.matcher})$`).test("Write");
313
- } catch {
314
- return FAIL(`${settingsRel}: the hook's matcher "${hit.matcher}" is not a pattern`);
315
- }
316
- if (!matches) return FAIL(`${settingsRel}: the hook runs only for "${hit.matcher}", never for a Write`);
317
- const top = ROOT.split("\\").join("/");
318
- const app = /--app\s+("?)([^"\s]+)\1/.exec(hit.command)?.[2] ?? ".";
319
- const input = JSON.stringify({
320
- hook_event_name: "PreToolUse",
321
- cwd: top,
322
- tool_name: "Write",
323
- tool_input: { file_path: `${resolve2(ROOT, app, "src", "__gates_hook_probe__.tsx").split("\\").join("/")}`, content: 'export function Probe() {\n return <div><button type="button">x</button></div>\n}\n' }
324
- });
325
- const command = hit.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, top);
326
- const run = spawnSync2(command, { cwd: ROOT, input, shell: true, encoding: "utf8", timeout: 12e4, env: { ...process.env, CLAUDE_PROJECT_DIR: top } });
327
- if (run.status === 2 && /<button>/.test(`${run.stderr ?? ""}${run.stdout ?? ""}`)) return PASS2(`${settingsRel}: the hook ran and refused a raw <button> (exit 2)`);
328
- const why = `${run.stderr ?? ""}${run.stdout ?? ""}`.trim().split(/\r?\n/)[0] || (run.error ? String(run.error.message) : "no output");
329
- return FAIL(`${settingsRel}: the hook ran and let a raw <button> through (exit ${run.status ?? "none"}): ${why}`);
330
- },
331
- json(rel, test = () => true, label) {
332
- if (!exists(rel)) return FAIL(`${rel} does not exist`);
333
- let data;
334
- try {
335
- data = JSON.parse(read(rel));
336
- } catch {
337
- return FAIL(`${rel} is not valid JSON`);
338
- }
339
- if (!git("ls-files", "--error-unmatch", rel)) return FAIL(`${rel} is not committed`);
340
- return test(data) ? PASS2(label ?? `${rel} is committed and parses`) : FAIL(`${rel} parses, but ${label ?? "fails its test"}`);
341
- },
342
- /** Lints a line of code that is never written to disk, the way the hook will. */
343
- async lint({ cwd = ".", config, file, code, expect, mentions }) {
344
- const cfg = join3(cwd, config);
345
- if (!exists(cfg)) return FAIL(`${cfg} does not exist`);
346
- let ESLint;
347
- try {
348
- const req = createRequire3(join3(abs(cwd), "package.json"));
349
- ({ ESLint } = await import(pathToFileURL3(req.resolve("eslint")).href));
350
- } catch (e) {
351
- return UNKNOWN(`could not load eslint from ${cwd}: ${said(e).message.split("\n")[0]}`);
352
- }
353
- let results;
354
- try {
355
- const eslint = new ESLint({ cwd: abs(cwd), overrideConfigFile: config });
356
- results = await eslint.lintText(code, { filePath: join3(abs(cwd), file), warnIgnored: true });
357
- } catch (e) {
358
- return UNKNOWN(`${cfg} could not lint ${file}: ${said(e).message.split("\n")[0]}`);
359
- }
360
- const messages = results.flatMap((r) => r.messages);
361
- const ignored = messages.some((m) => /ignored/i.test(m.message) && !m.ruleId);
362
- const fatal = messages.find((m) => m.fatal && !/ignored/i.test(m.message));
363
- if (fatal) return UNKNOWN(`${file} did not parse: ${fatal.message}`);
364
- const want = expect === "error" ? 2 : expect === "warning" ? 1 : 0;
365
- if (want === 0) {
366
- const any = messages.filter((m) => m.ruleId && m.severity === 2);
367
- return any.length ? FAIL(`${file} gets an error it should not: ${any[0].message}`) : PASS2(`${file}: no error${ignored ? " (not linted)" : ""}`);
368
- }
369
- if (ignored) return FAIL(`${cfg} does not lint ${file}`);
370
- const hit = messages.find((m) => m.ruleId && m.severity === want && (!mentions || m.message.includes(mentions)));
371
- const kind = want === 2 ? "error" : "warning";
372
- return hit ? PASS2(`${file} gets ${want === 2 ? "an" : "a"} ${kind}: ${hit.message}`) : FAIL(`${file} gets no ${kind}${mentions ? ` naming ${mentions}` : ""}`);
373
- },
374
- /**
375
- * A GitHub setting, not a file, so it is asked of GitHub: the checks a merge
376
- * into the default branch must pass, from the rulesets in force on it
377
- * (`rules/branches/<branch>`, which anyone who can read the repository may
378
- * see) and from classic protection as `branches/<branch>` reports it. Not
379
- * `branches/<branch>/protection`: GitHub answers "Not Found" there to anyone
380
- * who is not an admin, and it knows nothing of rulesets (UIG-6).
381
- */
382
- protectedBranch(pattern) {
383
- const url = git("remote", "get-url", "origin");
384
- const slug = url?.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1];
385
- if (!slug) return UNKNOWN("no GitHub remote");
386
- const branch = (git("symbolic-ref", "--short", "refs/remotes/origin/HEAD") ?? "origin/main").replace(/^origin\//, "");
387
- const ask = (path) => JSON.parse(execFileSync("gh", ["api", path], { stdio: ["ignore", "pipe", "pipe"], timeout: 2e4 }).toString());
388
- let rules;
389
- let classic;
390
- try {
391
- rules = ask(`repos/${slug}/rules/branches/${branch}`);
392
- classic = ask(`repos/${slug}/branches/${branch}`).protection?.required_status_checks;
393
- } catch (e) {
394
- const s = said(e);
395
- return UNKNOWN(`could not ask GitHub (${s.code === "ENOENT" ? "gh is not installed" : s.text.split("\n")[0] || s.message})`);
396
- }
397
- const names = [
398
- ...rules.filter((r) => r.type === "required_status_checks").flatMap((r) => r.parameters?.required_status_checks ?? []).map((c) => c.context),
399
- ...classic?.contexts ?? [],
400
- ...(classic?.checks ?? []).map((c) => c.context)
401
- ];
402
- const hit = names.find((n) => pattern.test(n));
403
- if (hit) return PASS2(`${slug} ${branch} requires "${hit}"`);
404
- return FAIL(names.length ? `${slug} ${branch} requires ${names.map((n) => `"${n}"`).join(", ")}, none matching ${pattern}` : `${slug} ${branch} requires no check to merge`);
405
- },
406
- gh(args, label) {
407
- try {
408
- execFileSync("gh", args, { stdio: ["ignore", "pipe", "pipe"], timeout: 2e4 });
409
- return PASS2(label);
410
- } catch (e) {
411
- const s = said(e);
412
- if (/could not resolve|not found|HTTP 404/i.test(s.text)) return FAIL(`not yet: ${label}`);
413
- return UNKNOWN(`could not ask GitHub (${s.code === "ENOENT" ? "gh is not installed" : s.text.split("\n")[0] || s.message})`);
414
- }
415
- },
416
- /** How many of `files` pass `test`: all is a pass, some is a part. */
417
- share(files, test, label) {
418
- if (!files.length) return FAIL(`no files to check for: ${label}`);
419
- const ok = files.filter(test).length;
420
- const detail = `${ok} of ${files.length}: ${label}`;
421
- return ok === files.length ? PASS2(detail) : ok ? PART(detail) : FAIL(detail);
422
- }
423
- };
424
- }
425
- var STATUS = { done: "\u2705", started: "\u{1F6A7}", none: "\u2B1C", unknown: "\u2754" };
426
- function statusOf(checks) {
427
- const n = (r) => checks.filter((c) => c.result === r).length;
428
- const pass = n("pass"), part = n("part"), fail = n("fail"), unknown = n("unknown");
429
- if (!checks.length) return "unknown";
430
- if (pass === checks.length) return "done";
431
- if (pass + part > 0) return fail === 0 && unknown > 0 ? "unknown" : "started";
432
- return fail > 0 ? "none" : "unknown";
433
- }
434
- async function runRepo(ROOT, app, h) {
435
- const checksFile = join3(ROOT, app, "scripts", "gates-checks.mjs");
436
- const define = (await import(pathToFileURL3(checksFile).href)).default;
437
- const spec = define(h);
438
- const git = (...a) => {
439
- try {
440
- return execFileSync("git", a, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
441
- } catch {
442
- return null;
443
- }
444
- };
445
- const rows = [];
446
- for (const t of spec.tickets) {
447
- const checks = [];
448
- for (const c of t.checks ?? []) {
449
- if (c.aggregate) continue;
450
- let r;
451
- try {
452
- r = await c.run();
453
- } catch (e) {
454
- r = UNKNOWN(`the check threw: ${said(e).message}`);
455
- }
456
- checks.push({ repo: spec.repo, what: c.what, ...r });
457
- }
458
- rows.push({ ref: t.ref, title: t.title, owner: t.owner, checks });
459
- }
460
- return { spec, report: { schema: 1, repo: spec.repo, branch: git("rev-parse", "--abbrev-ref", "HEAD"), commit: git("rev-parse", "--short", "HEAD"), engine: ENGINE, rows } };
461
- }
462
- function runSibling(dir, app, shown) {
463
- const copy = join3(dir, "scripts", "gates-status.mjs");
464
- const installed = createRequire3(join3(dir, app, "package.json"));
465
- let script = copy;
466
- const args = ["--json"];
467
- if (!existsSync3(copy)) {
468
- try {
469
- script = join3(installed.resolve("@estiva-app/ui/package.json"), "..", "dist", "gates", "cli.js");
470
- args.unshift("status");
471
- if (app !== ".") args.push("--app", app);
472
- } catch {
473
- return { error: `${shown} has neither its own gates-status.mjs nor @estiva-app/ui installed${app === "." ? "" : ` in ${app}`}` };
474
- }
475
- if (!existsSync3(script)) return { error: `${shown} has an @estiva-app/ui with no gates engine: install 0.21.0 or later` };
476
- }
477
- try {
478
- const out = execFileSync(process.execPath, [script, ...args], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], timeout: 3e5, maxBuffer: 16 * 1024 * 1024 });
479
- return { report: JSON.parse(out.toString()) };
480
- } catch (e) {
481
- const err = e;
482
- return { error: `its gates:status failed: ${String(err.stderr ?? err.message).split("\n").filter(Boolean).slice(-1)[0]}` };
483
- }
484
- }
485
- function behindMain(dir) {
486
- const git = (...a) => {
487
- try {
488
- return execFileSync("git", a, { cwd: dir, stdio: ["ignore", "pipe", "ignore"], timeout: 3e4 }).toString().trim();
489
- } catch {
490
- return null;
491
- }
492
- };
493
- if (git("rev-parse", "--is-inside-work-tree") !== "true") return "";
494
- const fetched = git("fetch", "-q", "origin") !== null;
495
- const main = (git("symbolic-ref", "--short", "refs/remotes/origin/HEAD") ?? "origin/main").trim();
496
- const count = Number(git("rev-list", "--count", `HEAD..${main}`));
497
- const stale = fetched ? "" : " (could not fetch: compared with the last fetch)";
498
- if (!Number.isFinite(count) || count === 0) return stale ? ` \xB7${stale}` : "";
499
- return ` \xB7 \u26A0\uFE0F ${count} commit${count === 1 ? "" : "s"} behind ${main}${stale}: pull, or this reads old code`;
500
- }
501
- function pad(s, n) {
502
- const len = [...s].length;
503
- return len >= n ? s : s + " ".repeat(n - len);
504
- }
505
- function printRows(rows, { showOwner, detail }) {
506
- const lines = [];
507
- const width = Math.max(...rows.map((r) => [...r.title ?? ""].length));
508
- for (const r of rows) {
509
- const pass = r.checks.filter((c) => c.result === "pass").length;
510
- const count = r.checks.length ? `${pass} of ${r.checks.length}` : "no checks here";
511
- lines.push(`${STATUS[r.status ?? "unknown"]} ${pad(r.ref, 7)} ${pad(r.title ?? "", width)} ${showOwner ? pad(r.ownerRepo ?? "", 10) : ""}${count}`);
512
- if (detail || r.status === "started" || r.status === "unknown") {
513
- for (const c of r.checks) {
514
- const mark = { pass: "\u2713", part: "\xBD", fail: "\u2717", unknown: "?" }[c.result];
515
- lines.push(` ${mark} ${showOwner ? `${pad(c.repo, 10)} ` : ""}${c.what} \u2014 ${c.detail}`);
516
- }
517
- }
518
- }
519
- return lines.join("\n");
520
- }
521
- function summary(rows) {
522
- const n = (s) => rows.filter((r) => r.status === s).length;
523
- return `${STATUS.done} ${n("done")} done \xB7 ${STATUS.started} ${n("started")} started \xB7 ${STATUS.none} ${n("none")} not started \xB7 ${STATUS.unknown} ${n("unknown")} could not check`;
524
- }
525
- async function runStatus({ root = process.cwd(), app = ".", json = false, detail = false } = {}) {
526
- const ROOT = resolve2(root);
527
- const h = helpers(ROOT);
528
- const shown = (dir) => (relative3(ROOT, dir) || dir).replace(/\\/g, "/");
529
- const { spec, report } = await runRepo(ROOT, app, h);
530
- if (json) return JSON.stringify(report);
531
- const out = [];
532
- out.push("UI Guardrails \xB7 gates:status", "Read from the code. Nothing here is a hand-ticked list.", "");
533
- out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}${behindMain(ROOT)}`);
534
- if (!spec.all) {
535
- const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }));
536
- const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: "", status: statusOf(r.checks) }));
537
- out.push("", `Tickets ${spec.repo} owns (${own.length}):`, printRows(own, { showOwner: false, detail }));
538
- out.push("", `Parts of tickets another repo owns, checked here (${parts.length}):`, printRows(parts, { showOwner: false, detail }));
539
- out.push("", "For every ticket across the repos, run npm run gates:status in estiva-ui.");
540
- return out.join("\n");
541
- }
542
- const reports = [report];
543
- const found = [];
544
- for (const s of spec.siblings ?? []) {
545
- const dir = resolve2(ROOT, process.env[s.env] ?? s.path);
546
- if (!existsSync3(join3(dir, "package.json"))) {
547
- found.push({ name: s.name, note: `not found at ${shown(dir)} (set ${s.env} to point at it)`, missing: true });
548
- continue;
549
- }
550
- const r = runSibling(dir, s.app ?? ".", shown(dir));
551
- if (!r.report) {
552
- found.push({ name: s.name, note: r.error ?? "no report", missing: true });
553
- continue;
554
- }
555
- const copy = r.report.engine !== ENGINE ? r.report.engine.startsWith("@estiva-app/ui@") ? ` \xB7 on ${r.report.engine}'s engine` : " \xB7 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}${behindMain(dir)}` });
557
- reports.push(r.report);
558
- }
559
- for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`);
560
- const problems = [];
561
- const known = new Set(spec.all.map((t) => t.ref));
562
- for (const rep of reports) {
563
- for (const r of rep.rows.filter((r2) => !known.has(r2.ref))) problems.push(`${rep.repo} reports ${r.ref}, which is not one of the tickets`);
564
- }
565
- const rows = spec.all.map((t) => {
566
- const pieces = reports.flatMap((rep) => rep.rows.filter((r) => r.ref === t.ref).map((r) => ({ rep, r })));
567
- const claims = pieces.filter((p) => p.r.owner).map((p) => p.rep.repo);
568
- const ownerMissing = found.some((f) => f.name === t.owner && f.missing);
569
- if (!ownerMissing && (claims.length !== 1 || claims[0] !== t.owner)) {
570
- problems.push(`${t.ref}: should be owned by ${t.owner}, is claimed by ${claims.join(" and ") || "no repo"}`);
571
- }
572
- for (const p of pieces.filter((p2) => !p2.r.owner && !(t.parts ?? []).includes(p2.rep.repo))) {
573
- problems.push(`${t.ref}: ${p.rep.repo} checks a part of it, but the ticket list does not name ${p.rep.repo}`);
574
- }
575
- const checks = pieces.flatMap((p) => p.r.checks);
576
- for (const f of found.filter((f2) => f2.missing)) {
577
- 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 });
578
- }
579
- return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate };
580
- });
581
- for (const row of rows) row.status = statusOf(row.checks);
582
- for (const row of rows.filter((r) => r.aggregate)) {
583
- const others = rows.filter((r) => r !== row);
584
- const done = others.filter((r) => r.status === "done").length;
585
- row.checks.push({ repo: "all", what: "every other ticket is done", ...done === others.length ? PASS2(`${done} of ${others.length} done`) : FAIL(`${done} of ${others.length} done`) });
586
- row.status = statusOf(row.checks);
587
- }
588
- out.push("", printRows(rows, { showOwner: true, detail }), "", summary(rows));
589
- const owners = Object.entries(rows.reduce((a, r) => ({ ...a, [r.ownerRepo ?? ""]: (a[r.ownerRepo ?? ""] ?? 0) + 1 }), {})).map(([k, v]) => `${k} ${v}`).join(", ");
590
- out.push(`${rows.length} tickets. Owned by ${owners}.`);
591
- const unchecked = found.filter((f) => f.missing && spec.all.some((t) => t.owner === f.name)).map((f) => f.name);
592
- if (problems.length) out.push(`\u26A0\uFE0F Ownership does not add up:
593
- ${problems.join("\n ")}`);
594
- else if (unchecked.length) out.push(`\u2754 Ownership not fully checked: ${unchecked.join(" and ")} could not be read.`);
595
- else out.push("\u2705 Each ticket is owned by exactly one repo, and every repo agrees.");
596
- if (!detail) out.push("Add -- --detail to see every check.");
597
- return out.join("\n");
598
- }
599
-
600
- export {
601
- writeGateCount,
602
- runHook,
603
- ENGINE,
604
- helpers,
605
- runStatus
606
- };
607
- //# sourceMappingURL=chunk-DBMUQAR6.js.map