@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,81 @@
1
+ import type { Rule, Scope } from 'eslint';
2
+ /**
3
+ * A part of the package is placed from outside, never restyled (UIG-9; Katerina's
4
+ * ruling B8 of 13 September, "only position and spacing pass into our
5
+ * components").
6
+ *
7
+ * Every part takes `className`, and some take a second class prop for an inner
8
+ * box (`contentClassName`, `bodyClassName`…). Through either, an app could push
9
+ * a colour, a text size or a border into a part and quietly undo it: the token
10
+ * lint only asks whether a class is a real token, never whether the part should
11
+ * take it. This rule reads every class passed into a part and lets through only
12
+ * placement — where the part sits and how much room it takes.
13
+ *
14
+ * **Which elements are parts: by where they come from, never by name.** No list
15
+ * of names is kept, so a part added to the package later is covered the day it
16
+ * is added.
17
+ *
18
+ * - In an app: anything imported from `@estiva-app/ui`, and anything imported
19
+ * from the app's own files that is one of those — a re-export (Peek's
20
+ * `components/ui/Button.tsx`), a wrapper that hands its props on to a part
21
+ * (`{...rest}`), or a component that hands its own `className` on to a part
22
+ * (Peek's `ConversationCard` into `Card`). Relative imports and the apps' `@/`
23
+ * alias are followed; `@/` is the `src` folder beside the nearest
24
+ * `package.json`.
25
+ * - Inside the package (Katerina, 17 September): a name `src/index.ts` exports,
26
+ * imported from a sibling file or declared in the same file.
27
+ *
28
+ * **What it reads:** a string, a template, `cn()`/`clsx()` and their arguments,
29
+ * both sides of a condition, a `const` in the same file and the values of a
30
+ * class map read from one. A class the code works out while it runs passes: the
31
+ * rule cannot read it.
32
+ *
33
+ * `EmptyState` takes no padding either (Katerina's ruling 6 of 14 September):
34
+ * its room comes from the box its rows live in.
35
+ */
36
+ /** Where a part sits and how much room it takes. Read on the utility, after its variants. */
37
+ export declare const PLACEMENT: {
38
+ what: string;
39
+ pattern: RegExp;
40
+ }[];
41
+ /** A class list's variants and its utility: `sm:[&>*]:shrink-0` → `['sm', '[&>*]']`, `shrink-0`. */
42
+ export declare function splitClass(token: string): {
43
+ variants: string[];
44
+ utility: string;
45
+ };
46
+ /** Whether a class only places the part. */
47
+ export declare function isPlacement(token: string): boolean;
48
+ /**
49
+ * The props a part has for how it looks, named in the message. Held to the
50
+ * package's source by `no-restyled-part.test.ts`: every prop here exists, and
51
+ * every look prop an exported part has is here. A part missing from this table
52
+ * has none, and the message says to ask for one.
53
+ */
54
+ export declare const PART_LOOK_PROPS: Record<string, string[]>;
55
+ interface Node {
56
+ type: string;
57
+ range: [number, number];
58
+ loc?: NonNullable<Rule.Node['loc']>;
59
+ parent?: Node;
60
+ [key: string]: unknown;
61
+ }
62
+ /**
63
+ * What a name is to this rule: the part it draws, and which of the caller's
64
+ * class props reach which of the part's (`all` for a part, a re-export or a
65
+ * wrapper; a map for a component that hands on its own `className`). `via` is
66
+ * the app's own component in between, for the message.
67
+ */
68
+ export interface PartBinding {
69
+ part: string;
70
+ props: 'all' | Record<string, string>;
71
+ via?: string;
72
+ }
73
+ /**
74
+ * Every class the code spells out for a value, as far as it can be read in this
75
+ * file. `asMap` reads an object as a class map (its values) rather than as a
76
+ * `clsx` object (its keys).
77
+ */
78
+ export declare function classesOf(value: Node | null | undefined, scopeOf: (node: Node) => Scope.Scope | null, asMap?: boolean, seen?: Set<Node>): string[];
79
+ export declare const noRestyledPart: Rule.RuleModule;
80
+ export {};
81
+ //# sourceMappingURL=no-restyled-part.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-restyled-part.d.ts","sourceRoot":"","sources":["../../src/eslint/no-restyled-part.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AAGzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAIH,6FAA6F;AAC7F,eAAO,MAAM,SAAS,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAUxD,CAAA;AASD,oGAAoG;AACpG,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAajF;AAED,4CAA4C;AAC5C,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIlD;AAOD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAgCpD,CAAA;AAOD,UAAU,IAAI;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvB,GAAG,CAAC,EAAE,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;IACnC,MAAM,CAAC,EAAE,IAAI,CAAA;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAwBD;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AA8UD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,KAAK,CAAC,KAAK,GAAG,IAAI,EAAE,KAAK,UAAQ,EAAE,IAAI,YAAkB,GAAG,MAAM,EAAE,CAqDtJ;AAOD,eAAO,MAAM,cAAc,EAAE,IAAI,CAAC,UAwEjC,CAAA"}
@@ -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,CAyJpP"}
@@ -0,0 +1,477 @@
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, h) {
322
+ const checksFile = join3(ROOT, "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, shown) {
350
+ const copy = join3(dir, "scripts", "gates-status.mjs");
351
+ const installed = createRequire3(join3(dir, "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
+ } catch {
359
+ return { error: `${shown} has neither its own gates-status.mjs nor @estiva-app/ui installed` };
360
+ }
361
+ if (!existsSync3(script)) return { error: `${shown} has an @estiva-app/ui with no gates engine: install 0.21.0 or later` };
362
+ }
363
+ try {
364
+ const out = execFileSync(process.execPath, [script, ...args], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], timeout: 3e5, maxBuffer: 16 * 1024 * 1024 });
365
+ return { report: JSON.parse(out.toString()) };
366
+ } catch (e) {
367
+ const err = e;
368
+ return { error: `its gates:status failed: ${String(err.stderr ?? err.message).split("\n").filter(Boolean).slice(-1)[0]}` };
369
+ }
370
+ }
371
+ function pad(s, n) {
372
+ const len = [...s].length;
373
+ return len >= n ? s : s + " ".repeat(n - len);
374
+ }
375
+ function printRows(rows, { showOwner, detail }) {
376
+ const lines = [];
377
+ const width = Math.max(...rows.map((r) => [...r.title ?? ""].length));
378
+ for (const r of rows) {
379
+ const pass = r.checks.filter((c) => c.result === "pass").length;
380
+ const count = r.checks.length ? `${pass} of ${r.checks.length}` : "no checks here";
381
+ lines.push(`${STATUS[r.status ?? "unknown"]} ${pad(r.ref, 7)} ${pad(r.title ?? "", width)} ${showOwner ? pad(r.ownerRepo ?? "", 10) : ""}${count}`);
382
+ if (detail || r.status === "started" || r.status === "unknown") {
383
+ for (const c of r.checks) {
384
+ const mark = { pass: "\u2713", part: "\xBD", fail: "\u2717", unknown: "?" }[c.result];
385
+ lines.push(` ${mark} ${showOwner ? `${pad(c.repo, 10)} ` : ""}${c.what} \u2014 ${c.detail}`);
386
+ }
387
+ }
388
+ }
389
+ return lines.join("\n");
390
+ }
391
+ function summary(rows) {
392
+ const n = (s) => rows.filter((r) => r.status === s).length;
393
+ 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`;
394
+ }
395
+ async function runStatus({ root = process.cwd(), json = false, detail = false } = {}) {
396
+ const ROOT = resolve2(root);
397
+ const h = helpers(ROOT);
398
+ const shown = (dir) => (relative3(ROOT, dir) || dir).replace(/\\/g, "/");
399
+ const { spec, report } = await runRepo(ROOT, h);
400
+ if (json) return JSON.stringify(report);
401
+ const out = [];
402
+ out.push("UI Guardrails \xB7 gates:status", "Read from the code. Nothing here is a hand-ticked list.", "");
403
+ out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}`);
404
+ if (!spec.all) {
405
+ const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }));
406
+ const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: "", status: statusOf(r.checks) }));
407
+ out.push("", `Tickets ${spec.repo} owns (${own.length}):`, printRows(own, { showOwner: false, detail }));
408
+ out.push("", `Parts of tickets another repo owns, checked here (${parts.length}):`, printRows(parts, { showOwner: false, detail }));
409
+ out.push("", "For every ticket across the repos, run npm run gates:status in estiva-ui.");
410
+ return out.join("\n");
411
+ }
412
+ const reports = [report];
413
+ const found = [];
414
+ for (const s of spec.siblings ?? []) {
415
+ const dir = resolve2(ROOT, process.env[s.env] ?? s.path);
416
+ if (!existsSync3(join3(dir, "package.json"))) {
417
+ found.push({ name: s.name, note: `not found at ${shown(dir)} (set ${s.env} to point at it)`, missing: true });
418
+ continue;
419
+ }
420
+ const r = runSibling(dir, shown(dir));
421
+ if (!r.report) {
422
+ found.push({ name: s.name, note: r.error ?? "no report", missing: true });
423
+ continue;
424
+ }
425
+ 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" : "";
426
+ found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}` });
427
+ reports.push(r.report);
428
+ }
429
+ for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`);
430
+ const problems = [];
431
+ const known = new Set(spec.all.map((t) => t.ref));
432
+ for (const rep of reports) {
433
+ 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`);
434
+ }
435
+ const rows = spec.all.map((t) => {
436
+ const pieces = reports.flatMap((rep) => rep.rows.filter((r) => r.ref === t.ref).map((r) => ({ rep, r })));
437
+ const claims = pieces.filter((p) => p.r.owner).map((p) => p.rep.repo);
438
+ const ownerMissing = found.some((f) => f.name === t.owner && f.missing);
439
+ if (!ownerMissing && (claims.length !== 1 || claims[0] !== t.owner)) {
440
+ problems.push(`${t.ref}: should be owned by ${t.owner}, is claimed by ${claims.join(" and ") || "no repo"}`);
441
+ }
442
+ for (const p of pieces.filter((p2) => !p2.r.owner && !(t.parts ?? []).includes(p2.rep.repo))) {
443
+ problems.push(`${t.ref}: ${p.rep.repo} checks a part of it, but the ticket list does not name ${p.rep.repo}`);
444
+ }
445
+ const checks = pieces.flatMap((p) => p.r.checks);
446
+ for (const f of found.filter((f2) => f2.missing)) {
447
+ 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 });
448
+ }
449
+ return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate };
450
+ });
451
+ for (const row of rows) row.status = statusOf(row.checks);
452
+ for (const row of rows.filter((r) => r.aggregate)) {
453
+ const others = rows.filter((r) => r !== row);
454
+ const done = others.filter((r) => r.status === "done").length;
455
+ 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`) });
456
+ row.status = statusOf(row.checks);
457
+ }
458
+ out.push("", printRows(rows, { showOwner: true, detail }), "", summary(rows));
459
+ const owners = Object.entries(rows.reduce((a, r) => ({ ...a, [r.ownerRepo ?? ""]: (a[r.ownerRepo ?? ""] ?? 0) + 1 }), {})).map(([k, v]) => `${k} ${v}`).join(", ");
460
+ out.push(`${rows.length} tickets. Owned by ${owners}.`);
461
+ const unchecked = found.filter((f) => f.missing && spec.all.some((t) => t.owner === f.name)).map((f) => f.name);
462
+ if (problems.length) out.push(`\u26A0\uFE0F Ownership does not add up:
463
+ ${problems.join("\n ")}`);
464
+ else if (unchecked.length) out.push(`\u2754 Ownership not fully checked: ${unchecked.join(" and ")} could not be read.`);
465
+ else out.push("\u2705 Each ticket is owned by exactly one repo, and every repo agrees.");
466
+ if (!detail) out.push("Add -- --detail to see every check.");
467
+ return out.join("\n");
468
+ }
469
+
470
+ export {
471
+ writeGateCount,
472
+ runHook,
473
+ ENGINE,
474
+ helpers,
475
+ runStatus
476
+ };
477
+ //# sourceMappingURL=chunk-AWO7SZSA.js.map