@estiva-app/ui 0.20.0 → 0.21.1

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 (44) hide show
  1. package/README.md +38 -2
  2. package/dist/gates/app-checks.d.ts +40 -0
  3. package/dist/gates/app-checks.d.ts.map +1 -0
  4. package/dist/gates/chunk-AUXD4GCY.js +478 -0
  5. package/dist/gates/chunk-AUXD4GCY.js.map +7 -0
  6. package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
  7. package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
  8. package/dist/gates/cli.d.ts +2 -0
  9. package/dist/gates/cli.d.ts.map +1 -0
  10. package/dist/gates/cli.js +46 -0
  11. package/dist/gates/cli.js.map +7 -0
  12. package/dist/gates/count.d.ts +20 -0
  13. package/dist/gates/count.d.ts.map +1 -0
  14. package/dist/gates/create-app-cli.d.ts +2 -0
  15. package/dist/gates/create-app-cli.d.ts.map +1 -0
  16. package/dist/gates/create-app.d.ts +23 -0
  17. package/dist/gates/create-app.d.ts.map +1 -0
  18. package/dist/gates/create-app.js +30 -0
  19. package/dist/gates/create-app.js.map +7 -0
  20. package/dist/gates/gate-config.d.ts +44 -0
  21. package/dist/gates/gate-config.d.ts.map +1 -0
  22. package/dist/gates/hook.d.ts +18 -0
  23. package/dist/gates/hook.d.ts.map +1 -0
  24. package/dist/gates/index.d.ts +30 -0
  25. package/dist/gates/index.d.ts.map +1 -0
  26. package/dist/gates/index.js +417 -0
  27. package/dist/gates/index.js.map +7 -0
  28. package/dist/gates/status.d.ts +88 -0
  29. package/dist/gates/status.d.ts.map +1 -0
  30. package/dist/gates/token-lint.d.ts +26 -0
  31. package/dist/gates/token-lint.d.ts.map +1 -0
  32. package/package.json +30 -4
  33. package/src/gates/app-checks.ts +226 -0
  34. package/src/gates/cli.ts +61 -0
  35. package/src/gates/count.ts +76 -0
  36. package/src/gates/create-app-cli.ts +25 -0
  37. package/src/gates/create-app.test.ts +72 -0
  38. package/src/gates/create-app.ts +797 -0
  39. package/src/gates/gate-config.ts +78 -0
  40. package/src/gates/gates.test.ts +194 -0
  41. package/src/gates/hook.ts +111 -0
  42. package/src/gates/index.ts +30 -0
  43. package/src/gates/status.ts +545 -0
  44. package/src/gates/token-lint.ts +231 -0
package/README.md CHANGED
@@ -67,7 +67,7 @@ export default {
67
67
 
68
68
  The UI Guardrails' rules ship with the package, as an ESLint plugin, so an app
69
69
  gets a new rule with a version bump. Each rule names the component to use
70
- instead. There are two:
70
+ instead. There are three:
71
71
 
72
72
  - `estiva/no-raw-element` refuses a raw interactive element (`<a>`, `<input>`,
73
73
  `<form>`, `<dialog>`…) and names the part to use, or says the package has none yet.
@@ -78,6 +78,10 @@ instead. There are two:
78
78
  (`overflow-auto`, behind any variant). `OWNED_BEHAVIOURS` lists each behaviour,
79
79
  the Base UI parts that do it, and the components that own it. It cannot find a
80
80
  box that should scroll and does not: that has no class to read.
81
+ - `estiva/no-restyled-part` refuses a look passed into a part through
82
+ `className` or an inner box's class prop — a colour, a text size, a border, a
83
+ corner, a shadow — and names the part's look props (`PART_LOOK_PROPS`). Only
84
+ placement goes through (`PLACEMENT`), and `EmptyState` takes no padding.
81
85
 
82
86
  ```js
83
87
  // eslint.config.js — alongside your own rules
@@ -99,7 +103,39 @@ or, as a JSX child, `{/* @estiva-escape: the reason */}`. Not `eslint-disable`:
99
103
  it switches the rule off without saying why, and the gate counts it as a
100
104
  failure. `countGates(results)` counts errors and escapes for
101
105
  `.gates-count.json`; lint with `settings: { estiva: { reportEscapes: true } }`
102
- for the escapes to be counted.
106
+ for the escapes to be counted. `estiva-gates count` does both for you (below).
107
+
108
+ ### The gate pieces — `@estiva-app/ui/gates`
109
+
110
+ Everything else an app needs to be gated ships here too, once, so an app never
111
+ carries a copy (`docs/GATES.md` §23). It needs `eslint`, `typescript-eslint` and
112
+ `eslint-plugin-better-tailwindcss` installed beside it.
113
+
114
+ - `tokenLint()` and `tokenValues()` — the token contract as lint: only the
115
+ preset's names, no hand-written values, no colour in an inline style.
116
+ - `gateConfig()` — the gate lint on its own (`eslint.gates.config.js`): the
117
+ plugin's rules, with the token plugins registered so their disable comments
118
+ still resolve.
119
+ - `estiva-gates count --repo <name>` — lint with the gate config and write
120
+ `.gates-count.json`; fails if an error or an unwritten reason appears.
121
+ - `estiva-gates hook` — the same refusal before a file is written, for a Claude
122
+ Code `PreToolUse` hook in `.claude/settings.json`.
123
+ - `estiva-gates status` — `gates:status`, read from the code.
124
+ - `appChecks(h)` — the checks every app runs in its `scripts/gates-checks.mjs`.
125
+ `npm run gates:compare` holds them to the checks Peek and Ship run.
126
+
127
+ ### A new app — `create-estiva-app`
128
+
129
+ ```bash
130
+ npx -p @estiva-app/ui create-estiva-app leaf --title Leaf
131
+ ```
132
+
133
+ It makes a folder that builds, runs and signs in with Estiva ID, with every gate
134
+ on from its first commit: the lint rules and the token lint at zero, the count
135
+ file, the editor hook, the CI job `gate`, and `gates:status`. It reads nothing
136
+ but this package. The frame is `AppShell` with a `Sidebar`, one theme
137
+ (`--theme`, default `light`) set once in `index.html`. Its README says what is
138
+ left to do by hand: register the app with Estiva ID, and protect `main`.
103
139
 
104
140
  ## The rules
105
141
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The gate checks every app runs, written once (docs/GATES.md §23).
3
+ *
4
+ * Until UIG-10 Peek and Ship each wrote them into their own
5
+ * `scripts/gates-checks.mjs`. 65 different checks for finished tickets, counted
6
+ * on 17 September: 28 were identical in both, 21 were the same check written two
7
+ * ways, 2 read each app's real page, and 14 read code only one app has. This is
8
+ * the first three groups as one list. The fourth stays in the app's own checks
9
+ * file, beside this list.
10
+ *
11
+ * Where Peek and Ship wrote one check two ways, the check here runs both, so it
12
+ * passes wherever either did: the raw-link probe with both of their texts, the
13
+ * token probes against each token config the app has.
14
+ *
15
+ * `scripts/gates-compare.mjs` holds this list to Peek's and Ship's: every check
16
+ * of theirs must be here, or be about code only that app has, or the comparison
17
+ * fails and names it.
18
+ *
19
+ * A check for a ticket not built yet is a first guess at the evidence, taken
20
+ * from the ticket's own text; the ticket that builds it changes it here.
21
+ */
22
+ import type { GateHelpers, GateTicket } from './status';
23
+ export interface AppCheckOptions {
24
+ /** Where the app is inside the repository: `web` in Ship, `.` elsewhere. */
25
+ app?: string;
26
+ /** A real page of the app, relative to the app, full of elements that are not controls. */
27
+ page: string;
28
+ /** The ticket the gate chain is listed under, and whether this repo owns it: UIG-3 in Peek, UIG-4 in Ship. */
29
+ chain?: {
30
+ ref: string;
31
+ owner: boolean;
32
+ };
33
+ /** The token lint configs the app has, relative to the app. */
34
+ tokenConfigs?: string[];
35
+ /** The name of the app's header component a hand-made header row should name (UIG-22's first guess). */
36
+ headers?: string[];
37
+ }
38
+ export declare const APP_TICKET_TITLES: Record<string, string>;
39
+ export declare function appChecks(h: GateHelpers, { app, page, chain, tokenConfigs, headers }: AppCheckOptions): GateTicket[];
40
+ //# sourceMappingURL=app-checks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-checks.d.ts","sourceRoot":"","sources":["../../src/gates/app-checks.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAA0B,WAAW,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAE/E,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,2FAA2F;IAC3F,IAAI,EAAE,MAAM,CAAA;IACZ,8GAA8G;IAC9G,KAAK,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAA;IACvC,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB,wGAAwG;IACxG,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAoBpD,CAAA;AAaD,wBAAgB,SAAS,CAAC,CAAC,EAAE,WAAW,EAAE,EAAE,GAAS,EAAE,IAAI,EAAE,KAAsC,EAAE,YAA8D,EAAE,OAA8C,EAAE,EAAE,eAAe,GAAG,UAAU,EAAE,CA2JpP"}
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/gates/count.ts
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { join, relative } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS } from "../eslint/index.js";
9
+ async function eslintFrom(root) {
10
+ const resolved = createRequire(join(root, "package.json")).resolve("eslint");
11
+ return (await import(pathToFileURL(resolved).href)).ESLint;
12
+ }
13
+ async function writeGateCount({ root = process.cwd(), repo, audience = "app", config = "eslint.gates.config.js" }) {
14
+ const out = join(root, ".gates-count.json");
15
+ const ESLint = await eslintFrom(root);
16
+ const eslint = new ESLint({
17
+ cwd: root,
18
+ overrideConfigFile: join(root, config),
19
+ overrideConfig: { settings: { estiva: { reportEscapes: true } } }
20
+ });
21
+ const { rules, disabled } = countGates(await eslint.lintFiles(["."]), audience === "package" ? PACKAGE_RULE_IDS : APP_RULE_IDS);
22
+ const previous = existsSync(out) ? JSON.parse(readFileSync(out, "utf8")) : null;
23
+ const changed = JSON.stringify(previous?.rules) !== JSON.stringify(rules);
24
+ if (changed) {
25
+ const count = { schemaVersion: 1, repo, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), rules };
26
+ writeFileSync(out, `${JSON.stringify(count, null, 2)}
27
+ `);
28
+ }
29
+ const lines = [changed ? ".gates-count.json: written" : ".gates-count.json: unchanged"];
30
+ for (const [id, c] of Object.entries(rules)) lines.push(` ${id}: ${c.errors} errors, ${c.warnings} warnings, ${c.escapes} escapes`);
31
+ const failures = [];
32
+ if (disabled.length > 0) {
33
+ failures.push("", "An eslint-disable switched a gate rule off. Write the reason above the element instead: // @estiva-escape: <reason>");
34
+ for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`);
35
+ }
36
+ return { rules, disabled, changed, lines, failures };
37
+ }
38
+
39
+ // src/gates/hook.ts
40
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
41
+ import { createRequire as createRequire2 } from "node:module";
42
+ import { isAbsolute, join as join2, relative as relative2, resolve, sep } from "node:path";
43
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
44
+ import { countGates as countGates2 } from "../eslint/index.js";
45
+ var PASS = { code: 0, message: "" };
46
+ async function runHook({ root, app = ".", audience = "app", input } = {}) {
47
+ let call;
48
+ try {
49
+ call = input ?? JSON.parse(readFileSync2(0, "utf8"));
50
+ } catch {
51
+ return PASS;
52
+ }
53
+ const args = call.tool_input ?? {};
54
+ if (typeof args.file_path !== "string") return PASS;
55
+ const top = root ?? process.env.CLAUDE_PROJECT_DIR ?? call.cwd ?? process.cwd();
56
+ const appDir = resolve(top, app);
57
+ const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path);
58
+ const rel = relative2(appDir, file).split(sep).join("/");
59
+ const source = audience === "package" ? /^src\/.+\.tsx$/ : /^src\/.+\.tsx?$/;
60
+ if (rel.startsWith("../") || isAbsolute(rel) || !source.test(rel) || /\.test\.tsx?$/.test(rel) || /\.d\.ts$/.test(rel)) return PASS;
61
+ const text = proposed(call.tool_name, args, file);
62
+ if (text === null) return PASS;
63
+ const resolved = createRequire2(join2(appDir, "package.json")).resolve("eslint");
64
+ const { ESLint } = await import(pathToFileURL2(resolved).href);
65
+ const config = join2(appDir, "eslint.gates.config.js");
66
+ const eslint = new ESLint({ cwd: appDir, overrideConfigFile: config });
67
+ const results = await eslint.lintText(text, { filePath: file });
68
+ const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId));
69
+ const { disabled } = countGates2(results);
70
+ if (errors.length === 0 && disabled.length === 0) return PASS;
71
+ const shown = (p) => relative2(top, p).split(sep).join("/");
72
+ const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`];
73
+ for (const m of errors) lines.push(` ${m.line}:${m.column} ${m.message}${m.ruleId ? ` (${m.ruleId})` : ""}`);
74
+ const keep = audience === "package" ? "Keep it only" : "Keep the element only";
75
+ 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>`);
76
+ return { code: 2, message: `${lines.join("\n")}
77
+ ` };
78
+ }
79
+ function proposed(tool, args, file) {
80
+ if (tool === "Write") return typeof args.content === "string" ? args.content : null;
81
+ if (tool !== "Edit" || typeof args.old_string !== "string" || typeof args.new_string !== "string") return null;
82
+ const { old_string: from, new_string: to } = args;
83
+ const current = existsSync2(file) ? readFileSync2(file, "utf8") : "";
84
+ for (const text of [current, current.replace(/\r\n/g, "\n")]) {
85
+ if (!text.includes(from)) continue;
86
+ return args.replace_all === true ? text.split(from).join(to) : text.replace(from, () => to);
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // src/gates/status.ts
92
+ import { execFileSync } from "node:child_process";
93
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
94
+ import { createRequire as createRequire3 } from "node:module";
95
+ import { join as join3, relative as relative3, resolve as resolve2 } from "node:path";
96
+ import { pathToFileURL as pathToFileURL3 } from "node:url";
97
+ var { version } = createRequire3(import.meta.url)("../../package.json");
98
+ var ENGINE = `@estiva-app/ui@${version}`;
99
+ var PASS2 = (detail) => ({ result: "pass", detail });
100
+ var FAIL = (detail) => ({ result: "fail", detail });
101
+ var PART = (detail) => ({ result: "part", detail });
102
+ var UNKNOWN = (detail) => ({ result: "unknown", detail });
103
+ var said = (e) => {
104
+ const err = e;
105
+ return { text: `${err.stdout ?? ""}${err.stderr ?? ""}`, message: String(err.message ?? e), code: err.code };
106
+ };
107
+ function helpers(ROOT) {
108
+ const abs = (rel) => resolve2(ROOT, rel);
109
+ const exists = (rel) => existsSync3(abs(rel));
110
+ const read = (rel) => readFileSync3(abs(rel), "utf8");
111
+ const git = (...a) => {
112
+ try {
113
+ return execFileSync("git", a, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
114
+ } catch {
115
+ return null;
116
+ }
117
+ };
118
+ const listFiles = (rel, test) => {
119
+ const out = [];
120
+ const walk = (dir) => {
121
+ if (!existsSync3(dir)) return;
122
+ for (const name of readdirSync(dir)) {
123
+ if (["node_modules", ".git", "dist", "storybook-static", ".verify-shots"].includes(name)) continue;
124
+ const p = join3(dir, name);
125
+ if (statSync(p).isDirectory()) walk(p);
126
+ else if (test(name, p)) out.push(relative3(ROOT, p).replace(/\\/g, "/"));
127
+ }
128
+ };
129
+ walk(abs(rel));
130
+ return out;
131
+ };
132
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
+ return {
134
+ PASS: PASS2,
135
+ FAIL,
136
+ PART,
137
+ UNKNOWN,
138
+ exists,
139
+ read,
140
+ listFiles,
141
+ file(rel) {
142
+ return exists(rel) ? PASS2(`${rel} exists`) : FAIL(`${rel} does not exist`);
143
+ },
144
+ committed(rel) {
145
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
146
+ return git("ls-files", "--error-unmatch", rel) ? PASS2(`${rel} is committed`) : FAIL(`${rel} exists but is not committed`);
147
+ },
148
+ contains(rel, needle, label) {
149
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
150
+ const hit = typeof needle === "string" ? read(rel).includes(needle) : needle.test(read(rel));
151
+ return hit ? PASS2(label ?? `${rel} has ${needle}`) : FAIL(label ? `not yet: ${label}` : `${rel} has no ${needle}`);
152
+ },
153
+ lacks(rel, needle, label) {
154
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
155
+ const hit = typeof needle === "string" ? read(rel).includes(needle) : needle.test(read(rel));
156
+ return hit ? FAIL(`${rel} still has ${needle}`) : PASS2(label ?? `${rel} has no ${needle}`);
157
+ },
158
+ script(pkgRel, name) {
159
+ if (!exists(pkgRel)) return FAIL(`${pkgRel} does not exist`);
160
+ const cmd = JSON.parse(read(pkgRel)).scripts?.[name];
161
+ return cmd ? PASS2(`${pkgRel} runs "${name}": ${cmd}`) : FAIL(`${pkgRel} has no "${name}" script`);
162
+ },
163
+ async loads(rel) {
164
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
165
+ try {
166
+ const mod = await import(pathToFileURL3(abs(rel)).href);
167
+ return mod.default ? PASS2(`${rel} loads`) : FAIL(`${rel} has no default export`);
168
+ } catch (e) {
169
+ return FAIL(`${rel} does not load: ${said(e).message.split("\n")[0]}`);
170
+ }
171
+ },
172
+ /** A workflow step that runs `npm run <script>`; a regex matches the script name. */
173
+ ci(script) {
174
+ const files = listFiles(".github/workflows", (n) => /\.ya?ml$/.test(n));
175
+ const name = typeof script === "string" ? escape(script) : script.source;
176
+ const step = new RegExp(`npm run (${name})(?![\\w:-])`);
177
+ for (const f of files) {
178
+ const m = read(f).match(step);
179
+ if (m) return PASS2(`${f} runs npm run ${m[1]}`);
180
+ }
181
+ return FAIL(`no workflow runs npm run ${script}`);
182
+ },
183
+ /**
184
+ * A workflow job, by its id, with a step that runs `npm run <script>`. Branch
185
+ * protection requires a whole job by its name, so a required check needs the
186
+ * job, not the step somewhere (UIG-6). A job is a key two spaces in under
187
+ * `jobs:`, and ends at the next line that is not indented further; a comment
188
+ * line does not count.
189
+ */
190
+ ciJob(job, script) {
191
+ const files = listFiles(".github/workflows", (n) => /\.ya?ml$/.test(n));
192
+ const header = new RegExp(`^ ${escape(job)}:\\s*(#.*)?$`);
193
+ const step = new RegExp(`^(?!\\s*#).*npm run ${escape(script)}(?![\\w:-])`, "m");
194
+ for (const f of files) {
195
+ const lines = read(f).split(/\r?\n/);
196
+ const from = lines.findIndex((l) => /^jobs:\s*$/.test(l));
197
+ const start = from === -1 ? -1 : lines.findIndex((l, i) => i > from && header.test(l));
198
+ if (start === -1) continue;
199
+ const end = lines.findIndex((l, i) => i > start && /^ {0,2}\S/.test(l));
200
+ const body = lines.slice(start + 1, end === -1 ? void 0 : end).join("\n");
201
+ return step.test(body) ? PASS2(`${f}: the job ${job} runs npm run ${script}`) : FAIL(`${f}: the job ${job} does not run npm run ${script}`);
202
+ }
203
+ return FAIL(`no workflow has a job ${job}`);
204
+ },
205
+ hook(settingsRel, needle) {
206
+ if (!exists(settingsRel)) return FAIL(`${settingsRel} does not exist`);
207
+ let s;
208
+ try {
209
+ s = JSON.parse(read(settingsRel));
210
+ } catch {
211
+ return FAIL(`${settingsRel} is not valid JSON`);
212
+ }
213
+ const commands = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => x.command ?? ""));
214
+ const hit = commands.find((c) => c.includes(needle));
215
+ if (hit) return PASS2(`${settingsRel} has a PreToolUse hook running ${needle}`);
216
+ return FAIL(commands.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`);
217
+ },
218
+ json(rel, test = () => true, label) {
219
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
220
+ let data;
221
+ try {
222
+ data = JSON.parse(read(rel));
223
+ } catch {
224
+ return FAIL(`${rel} is not valid JSON`);
225
+ }
226
+ if (!git("ls-files", "--error-unmatch", rel)) return FAIL(`${rel} is not committed`);
227
+ return test(data) ? PASS2(label ?? `${rel} is committed and parses`) : FAIL(`${rel} parses, but ${label ?? "fails its test"}`);
228
+ },
229
+ /** Lints a line of code that is never written to disk, the way the hook will. */
230
+ async lint({ cwd = ".", config, file, code, expect, mentions }) {
231
+ const cfg = join3(cwd, config);
232
+ if (!exists(cfg)) return FAIL(`${cfg} does not exist`);
233
+ let ESLint;
234
+ try {
235
+ const req = createRequire3(join3(abs(cwd), "package.json"));
236
+ ({ ESLint } = await import(pathToFileURL3(req.resolve("eslint")).href));
237
+ } catch (e) {
238
+ return UNKNOWN(`could not load eslint from ${cwd}: ${said(e).message.split("\n")[0]}`);
239
+ }
240
+ let results;
241
+ try {
242
+ const eslint = new ESLint({ cwd: abs(cwd), overrideConfigFile: config });
243
+ results = await eslint.lintText(code, { filePath: join3(abs(cwd), file), warnIgnored: true });
244
+ } catch (e) {
245
+ return UNKNOWN(`${cfg} could not lint ${file}: ${said(e).message.split("\n")[0]}`);
246
+ }
247
+ const messages = results.flatMap((r) => r.messages);
248
+ const ignored = messages.some((m) => /ignored/i.test(m.message) && !m.ruleId);
249
+ const fatal = messages.find((m) => m.fatal && !/ignored/i.test(m.message));
250
+ if (fatal) return UNKNOWN(`${file} did not parse: ${fatal.message}`);
251
+ const want = expect === "error" ? 2 : expect === "warning" ? 1 : 0;
252
+ if (want === 0) {
253
+ const any = messages.filter((m) => m.ruleId && m.severity === 2);
254
+ return any.length ? FAIL(`${file} gets an error it should not: ${any[0].message}`) : PASS2(`${file}: no error${ignored ? " (not linted)" : ""}`);
255
+ }
256
+ if (ignored) return FAIL(`${cfg} does not lint ${file}`);
257
+ const hit = messages.find((m) => m.ruleId && m.severity === want && (!mentions || m.message.includes(mentions)));
258
+ const kind = want === 2 ? "error" : "warning";
259
+ return hit ? PASS2(`${file} gets ${want === 2 ? "an" : "a"} ${kind}: ${hit.message}`) : FAIL(`${file} gets no ${kind}${mentions ? ` naming ${mentions}` : ""}`);
260
+ },
261
+ /**
262
+ * A GitHub setting, not a file, so it is asked of GitHub: the checks a merge
263
+ * into the default branch must pass, from the rulesets in force on it
264
+ * (`rules/branches/<branch>`, which anyone who can read the repository may
265
+ * see) and from classic protection as `branches/<branch>` reports it. Not
266
+ * `branches/<branch>/protection`: GitHub answers "Not Found" there to anyone
267
+ * who is not an admin, and it knows nothing of rulesets (UIG-6).
268
+ */
269
+ protectedBranch(pattern) {
270
+ const url = git("remote", "get-url", "origin");
271
+ const slug = url?.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1];
272
+ if (!slug) return UNKNOWN("no GitHub remote");
273
+ const branch = (git("symbolic-ref", "--short", "refs/remotes/origin/HEAD") ?? "origin/main").replace(/^origin\//, "");
274
+ const ask = (path) => JSON.parse(execFileSync("gh", ["api", path], { stdio: ["ignore", "pipe", "pipe"], timeout: 2e4 }).toString());
275
+ let rules;
276
+ let classic;
277
+ try {
278
+ rules = ask(`repos/${slug}/rules/branches/${branch}`);
279
+ classic = ask(`repos/${slug}/branches/${branch}`).protection?.required_status_checks;
280
+ } catch (e) {
281
+ const s = said(e);
282
+ return UNKNOWN(`could not ask GitHub (${s.code === "ENOENT" ? "gh is not installed" : s.text.split("\n")[0] || s.message})`);
283
+ }
284
+ const names = [
285
+ ...rules.filter((r) => r.type === "required_status_checks").flatMap((r) => r.parameters?.required_status_checks ?? []).map((c) => c.context),
286
+ ...classic?.contexts ?? [],
287
+ ...(classic?.checks ?? []).map((c) => c.context)
288
+ ];
289
+ const hit = names.find((n) => pattern.test(n));
290
+ if (hit) return PASS2(`${slug} ${branch} requires "${hit}"`);
291
+ return FAIL(names.length ? `${slug} ${branch} requires ${names.map((n) => `"${n}"`).join(", ")}, none matching ${pattern}` : `${slug} ${branch} requires no check to merge`);
292
+ },
293
+ gh(args, label) {
294
+ try {
295
+ execFileSync("gh", args, { stdio: ["ignore", "pipe", "pipe"], timeout: 2e4 });
296
+ return PASS2(label);
297
+ } catch (e) {
298
+ const s = said(e);
299
+ if (/could not resolve|not found|HTTP 404/i.test(s.text)) return FAIL(`not yet: ${label}`);
300
+ return UNKNOWN(`could not ask GitHub (${s.code === "ENOENT" ? "gh is not installed" : s.text.split("\n")[0] || s.message})`);
301
+ }
302
+ },
303
+ /** How many of `files` pass `test`: all is a pass, some is a part. */
304
+ share(files, test, label) {
305
+ if (!files.length) return FAIL(`no files to check for: ${label}`);
306
+ const ok = files.filter(test).length;
307
+ const detail = `${ok} of ${files.length}: ${label}`;
308
+ return ok === files.length ? PASS2(detail) : ok ? PART(detail) : FAIL(detail);
309
+ }
310
+ };
311
+ }
312
+ var STATUS = { done: "\u2705", started: "\u{1F6A7}", none: "\u2B1C", unknown: "\u2754" };
313
+ function statusOf(checks) {
314
+ const n = (r) => checks.filter((c) => c.result === r).length;
315
+ const pass = n("pass"), part = n("part"), fail = n("fail"), unknown = n("unknown");
316
+ if (!checks.length) return "unknown";
317
+ if (pass === checks.length) return "done";
318
+ if (pass + part > 0) return fail === 0 && unknown > 0 ? "unknown" : "started";
319
+ return fail > 0 ? "none" : "unknown";
320
+ }
321
+ async function runRepo(ROOT, app, h) {
322
+ const checksFile = join3(ROOT, app, "scripts", "gates-checks.mjs");
323
+ const define = (await import(pathToFileURL3(checksFile).href)).default;
324
+ const spec = define(h);
325
+ const git = (...a) => {
326
+ try {
327
+ return execFileSync("git", a, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
328
+ } catch {
329
+ return null;
330
+ }
331
+ };
332
+ const rows = [];
333
+ for (const t of spec.tickets) {
334
+ const checks = [];
335
+ for (const c of t.checks ?? []) {
336
+ if (c.aggregate) continue;
337
+ let r;
338
+ try {
339
+ r = await c.run();
340
+ } catch (e) {
341
+ r = UNKNOWN(`the check threw: ${said(e).message}`);
342
+ }
343
+ checks.push({ repo: spec.repo, what: c.what, ...r });
344
+ }
345
+ rows.push({ ref: t.ref, title: t.title, owner: t.owner, checks });
346
+ }
347
+ return { spec, report: { schema: 1, repo: spec.repo, branch: git("rev-parse", "--abbrev-ref", "HEAD"), commit: git("rev-parse", "--short", "HEAD"), engine: ENGINE, rows } };
348
+ }
349
+ function runSibling(dir, app, shown) {
350
+ const copy = join3(dir, "scripts", "gates-status.mjs");
351
+ const installed = createRequire3(join3(dir, app, "package.json"));
352
+ let script = copy;
353
+ const args = ["--json"];
354
+ if (!existsSync3(copy)) {
355
+ try {
356
+ script = join3(installed.resolve("@estiva-app/ui/package.json"), "..", "dist", "gates", "cli.js");
357
+ args.unshift("status");
358
+ if (app !== ".") args.push("--app", app);
359
+ } catch {
360
+ return { error: `${shown} has neither its own gates-status.mjs nor @estiva-app/ui installed${app === "." ? "" : ` in ${app}`}` };
361
+ }
362
+ if (!existsSync3(script)) return { error: `${shown} has an @estiva-app/ui with no gates engine: install 0.21.0 or later` };
363
+ }
364
+ try {
365
+ const out = execFileSync(process.execPath, [script, ...args], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], timeout: 3e5, maxBuffer: 16 * 1024 * 1024 });
366
+ return { report: JSON.parse(out.toString()) };
367
+ } catch (e) {
368
+ const err = e;
369
+ return { error: `its gates:status failed: ${String(err.stderr ?? err.message).split("\n").filter(Boolean).slice(-1)[0]}` };
370
+ }
371
+ }
372
+ function pad(s, n) {
373
+ const len = [...s].length;
374
+ return len >= n ? s : s + " ".repeat(n - len);
375
+ }
376
+ function printRows(rows, { showOwner, detail }) {
377
+ const lines = [];
378
+ const width = Math.max(...rows.map((r) => [...r.title ?? ""].length));
379
+ for (const r of rows) {
380
+ const pass = r.checks.filter((c) => c.result === "pass").length;
381
+ const count = r.checks.length ? `${pass} of ${r.checks.length}` : "no checks here";
382
+ lines.push(`${STATUS[r.status ?? "unknown"]} ${pad(r.ref, 7)} ${pad(r.title ?? "", width)} ${showOwner ? pad(r.ownerRepo ?? "", 10) : ""}${count}`);
383
+ if (detail || r.status === "started" || r.status === "unknown") {
384
+ for (const c of r.checks) {
385
+ const mark = { pass: "\u2713", part: "\xBD", fail: "\u2717", unknown: "?" }[c.result];
386
+ lines.push(` ${mark} ${showOwner ? `${pad(c.repo, 10)} ` : ""}${c.what} \u2014 ${c.detail}`);
387
+ }
388
+ }
389
+ }
390
+ return lines.join("\n");
391
+ }
392
+ function summary(rows) {
393
+ const n = (s) => rows.filter((r) => r.status === s).length;
394
+ 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`;
395
+ }
396
+ async function runStatus({ root = process.cwd(), app = ".", json = false, detail = false } = {}) {
397
+ const ROOT = resolve2(root);
398
+ const h = helpers(ROOT);
399
+ const shown = (dir) => (relative3(ROOT, dir) || dir).replace(/\\/g, "/");
400
+ const { spec, report } = await runRepo(ROOT, app, h);
401
+ if (json) return JSON.stringify(report);
402
+ const out = [];
403
+ out.push("UI Guardrails \xB7 gates:status", "Read from the code. Nothing here is a hand-ticked list.", "");
404
+ out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}`);
405
+ if (!spec.all) {
406
+ const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }));
407
+ const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: "", status: statusOf(r.checks) }));
408
+ out.push("", `Tickets ${spec.repo} owns (${own.length}):`, printRows(own, { showOwner: false, detail }));
409
+ out.push("", `Parts of tickets another repo owns, checked here (${parts.length}):`, printRows(parts, { showOwner: false, detail }));
410
+ out.push("", "For every ticket across the repos, run npm run gates:status in estiva-ui.");
411
+ return out.join("\n");
412
+ }
413
+ const reports = [report];
414
+ const found = [];
415
+ for (const s of spec.siblings ?? []) {
416
+ const dir = resolve2(ROOT, process.env[s.env] ?? s.path);
417
+ if (!existsSync3(join3(dir, "package.json"))) {
418
+ found.push({ name: s.name, note: `not found at ${shown(dir)} (set ${s.env} to point at it)`, missing: true });
419
+ continue;
420
+ }
421
+ const r = runSibling(dir, s.app ?? ".", shown(dir));
422
+ if (!r.report) {
423
+ found.push({ name: s.name, note: r.error ?? "no report", missing: true });
424
+ continue;
425
+ }
426
+ 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" : "";
427
+ found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}` });
428
+ reports.push(r.report);
429
+ }
430
+ for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`);
431
+ const problems = [];
432
+ const known = new Set(spec.all.map((t) => t.ref));
433
+ for (const rep of reports) {
434
+ 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`);
435
+ }
436
+ const rows = spec.all.map((t) => {
437
+ const pieces = reports.flatMap((rep) => rep.rows.filter((r) => r.ref === t.ref).map((r) => ({ rep, r })));
438
+ const claims = pieces.filter((p) => p.r.owner).map((p) => p.rep.repo);
439
+ const ownerMissing = found.some((f) => f.name === t.owner && f.missing);
440
+ if (!ownerMissing && (claims.length !== 1 || claims[0] !== t.owner)) {
441
+ problems.push(`${t.ref}: should be owned by ${t.owner}, is claimed by ${claims.join(" and ") || "no repo"}`);
442
+ }
443
+ for (const p of pieces.filter((p2) => !p2.r.owner && !(t.parts ?? []).includes(p2.rep.repo))) {
444
+ problems.push(`${t.ref}: ${p.rep.repo} checks a part of it, but the ticket list does not name ${p.rep.repo}`);
445
+ }
446
+ const checks = pieces.flatMap((p) => p.r.checks);
447
+ for (const f of found.filter((f2) => f2.missing)) {
448
+ 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 });
449
+ }
450
+ return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate };
451
+ });
452
+ for (const row of rows) row.status = statusOf(row.checks);
453
+ for (const row of rows.filter((r) => r.aggregate)) {
454
+ const others = rows.filter((r) => r !== row);
455
+ const done = others.filter((r) => r.status === "done").length;
456
+ 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`) });
457
+ row.status = statusOf(row.checks);
458
+ }
459
+ out.push("", printRows(rows, { showOwner: true, detail }), "", summary(rows));
460
+ const owners = Object.entries(rows.reduce((a, r) => ({ ...a, [r.ownerRepo ?? ""]: (a[r.ownerRepo ?? ""] ?? 0) + 1 }), {})).map(([k, v]) => `${k} ${v}`).join(", ");
461
+ out.push(`${rows.length} tickets. Owned by ${owners}.`);
462
+ const unchecked = found.filter((f) => f.missing && spec.all.some((t) => t.owner === f.name)).map((f) => f.name);
463
+ if (problems.length) out.push(`\u26A0\uFE0F Ownership does not add up:
464
+ ${problems.join("\n ")}`);
465
+ else if (unchecked.length) out.push(`\u2754 Ownership not fully checked: ${unchecked.join(" and ")} could not be read.`);
466
+ else out.push("\u2705 Each ticket is owned by exactly one repo, and every repo agrees.");
467
+ if (!detail) out.push("Add -- --detail to see every check.");
468
+ return out.join("\n");
469
+ }
470
+
471
+ export {
472
+ writeGateCount,
473
+ runHook,
474
+ ENGINE,
475
+ helpers,
476
+ runStatus
477
+ };
478
+ //# sourceMappingURL=chunk-AUXD4GCY.js.map