@estiva-app/ui 0.35.2 → 0.36.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 (123) hide show
  1. package/dist/eslint/escape.d.ts +23 -6
  2. package/dist/eslint/escape.d.ts.map +1 -1
  3. package/dist/eslint/index.js +70 -9
  4. package/dist/eslint/index.js.map +2 -2
  5. package/dist/eslint/no-raw-element.d.ts +7 -0
  6. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  7. package/dist/eslint/no-restyled-part.d.ts +8 -0
  8. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  9. package/dist/gates/app-checks.d.ts.map +1 -1
  10. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  11. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  12. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  13. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  14. package/dist/gates/ci.d.ts +18 -0
  15. package/dist/gates/ci.d.ts.map +1 -0
  16. package/dist/gates/cli.js +20 -3
  17. package/dist/gates/cli.js.map +2 -2
  18. package/dist/gates/create-app.d.ts.map +1 -1
  19. package/dist/gates/create-app.js +1 -1
  20. package/dist/gates/escapes.d.ts +23 -0
  21. package/dist/gates/escapes.d.ts.map +1 -0
  22. package/dist/gates/gate-config.d.ts.map +1 -1
  23. package/dist/gates/hook.d.ts +2 -0
  24. package/dist/gates/hook.d.ts.map +1 -1
  25. package/dist/gates/index.d.ts +5 -1
  26. package/dist/gates/index.d.ts.map +1 -1
  27. package/dist/gates/index.js +37 -183
  28. package/dist/gates/index.js.map +4 -4
  29. package/dist/gates/name-escapes.d.ts +14 -0
  30. package/dist/gates/name-escapes.d.ts.map +1 -0
  31. package/dist/gates/status.d.ts +3 -1
  32. package/dist/gates/status.d.ts.map +1 -1
  33. package/dist/gates/token-lint.d.ts.map +1 -1
  34. package/dist/gates/token-style.d.ts +21 -0
  35. package/dist/gates/token-style.d.ts.map +1 -0
  36. package/dist/index.js +13 -13
  37. package/dist/index.js.map +1 -1
  38. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  39. package/dist/registry/app.d.ts.map +1 -1
  40. package/dist/registry/behaviours.d.ts +71 -0
  41. package/dist/registry/behaviours.d.ts.map +1 -0
  42. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  43. package/dist/registry/build.d.ts.map +1 -1
  44. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  45. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  46. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  47. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  48. package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  49. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  50. package/dist/registry/cli.js +7 -6
  51. package/dist/registry/cli.js.map +2 -2
  52. package/dist/registry/contract.d.ts +13 -1
  53. package/dist/registry/contract.d.ts.map +1 -1
  54. package/dist/registry/index.d.ts +1 -1
  55. package/dist/registry/index.d.ts.map +1 -1
  56. package/dist/registry/index.js +11 -5
  57. package/package.json +4 -1
  58. package/registry.json +293 -17
  59. package/skill/estiva-ui.md +2 -1
  60. package/src/AppShell.mdx +2 -1
  61. package/src/AttachmentCard.tsx +7 -7
  62. package/src/Button.tsx +1 -1
  63. package/src/Card.tsx +2 -2
  64. package/src/ChipInput.mdx +2 -0
  65. package/src/ChipInput.tsx +1 -1
  66. package/src/CommandPalette.tsx +3 -3
  67. package/src/DialogShell.tsx +1 -1
  68. package/src/EmptyState.mdx +4 -3
  69. package/src/Lightbox.tsx +1 -1
  70. package/src/NavItem.tsx +1 -1
  71. package/src/PersonTrigger.tsx +1 -1
  72. package/src/Reaction.tsx +1 -1
  73. package/src/ScrollArea.mdx +4 -0
  74. package/src/Select.tsx +1 -1
  75. package/src/TopBar.mdx +1 -1
  76. package/src/eslint/escape.ts +43 -14
  77. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  78. package/src/eslint/index.test.ts +2 -2
  79. package/src/eslint/no-copied-look.test.ts +1 -1
  80. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  81. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  82. package/src/eslint/no-handmade-header.test.ts +2 -2
  83. package/src/eslint/no-native-title.test.ts +2 -2
  84. package/src/eslint/no-raw-element.test.ts +47 -15
  85. package/src/eslint/no-raw-element.ts +53 -3
  86. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  87. package/src/eslint/no-restyled-part.test.ts +21 -2
  88. package/src/eslint/no-restyled-part.ts +30 -0
  89. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  90. package/src/gates/app-checks.ts +4 -3
  91. package/src/gates/ci.ts +152 -0
  92. package/src/gates/cli.ts +19 -3
  93. package/src/gates/count.ts +1 -1
  94. package/src/gates/create-app.test.ts +2 -2
  95. package/src/gates/create-app.ts +22 -16
  96. package/src/gates/escapes.ts +111 -0
  97. package/src/gates/gate-config.ts +2 -0
  98. package/src/gates/gates.test.ts +86 -1
  99. package/src/gates/hook.ts +38 -4
  100. package/src/gates/index.ts +5 -1
  101. package/src/gates/name-escapes.ts +77 -0
  102. package/src/gates/status.ts +45 -2
  103. package/src/gates/token-lint.ts +11 -4
  104. package/src/gates/token-style.ts +79 -0
  105. package/src/how.test.ts +282 -0
  106. package/src/pages.test.ts +6 -0
  107. package/src/registry/app.test.ts +34 -0
  108. package/src/registry/app.ts +52 -2
  109. package/src/registry/behaviours.ts +223 -0
  110. package/src/registry/build.ts +36 -0
  111. package/src/registry/cli.ts +5 -3
  112. package/src/registry/contract.test.ts +27 -2
  113. package/src/registry/contract.ts +28 -3
  114. package/src/registry/index.ts +1 -1
  115. package/src/registry/registry.test.ts +14 -0
  116. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  117. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  118. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  119. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  120. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  121. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  122. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  123. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -0,0 +1,1131 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ repositoryOf
4
+ } from "./chunk-YKZXKVR2.js";
5
+
6
+ // src/gates/token-lint.ts
7
+ import { createRequire } from "node:module";
8
+ import { fileURLToPath } from "node:url";
9
+ import betterTailwindcss from "eslint-plugin-better-tailwindcss";
10
+ import { getDefaultSelectors } from "eslint-plugin-better-tailwindcss/defaults";
11
+ import { parser as typescriptParser, plugin as typescriptPlugin } from "typescript-eslint";
12
+
13
+ // src/gates/token-style.ts
14
+ var INLINE_STYLE_TOKEN_KEYS = /^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/;
15
+ var INLINE_STYLE_MESSAGE = "An inline style that sets a colour, a font size, a border or a shadow is outside the token contract: use a token class. If the value is computed (a palette, a size from a prop), say why in an eslint-disable comment. Width, height and transforms are fine.";
16
+ var keyOf = (property) => {
17
+ if (property.computed) return void 0;
18
+ const key = property.key;
19
+ return key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : void 0;
20
+ };
21
+ function constObject(name, scope) {
22
+ for (let current = scope; current; current = current.upper) {
23
+ const variable = current.set.get(name);
24
+ if (!variable) continue;
25
+ const definition = variable.defs[0];
26
+ if (variable.defs.length !== 1 || definition?.type !== "Variable" || definition.parent?.kind !== "const") return void 0;
27
+ let init = definition.node.init;
28
+ while (init && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression")) init = init.expression;
29
+ return init?.type === "ObjectExpression" ? init : void 0;
30
+ }
31
+ return void 0;
32
+ }
33
+ var noTokenStyle = {
34
+ meta: {
35
+ type: "problem",
36
+ docs: { description: "No colour, font size, border or shadow in a style kept in a const" },
37
+ schema: [],
38
+ messages: { token: INLINE_STYLE_MESSAGE }
39
+ },
40
+ create(context) {
41
+ const reported = /* @__PURE__ */ new Set();
42
+ return {
43
+ JSXAttribute(ruleNode) {
44
+ const attribute = ruleNode;
45
+ if (attribute.name.name !== "style") return;
46
+ const value = attribute.value;
47
+ const expression = value?.type === "JSXExpressionContainer" ? value.expression : void 0;
48
+ if (expression?.type !== "Identifier") return;
49
+ const object = constObject(expression.name, context.sourceCode.getScope(ruleNode));
50
+ for (const property of object?.properties ?? []) {
51
+ if (property.type !== "Property" || reported.has(property)) continue;
52
+ const key = keyOf(property);
53
+ if (!key || !INLINE_STYLE_TOKEN_KEYS.test(key)) continue;
54
+ reported.add(property);
55
+ context.report({ node: property, messageId: "token" });
56
+ }
57
+ }
58
+ };
59
+ }
60
+ };
61
+ var tokenStylePlugin = { rules: { "no-token-style": noTokenStyle } };
62
+
63
+ // src/gates/token-lint.ts
64
+ var TOKEN_LINT_IGNORES = ["dist/", "storybook-static/", "node_modules/"];
65
+ var KNOWN_PLUGINS = { "@typescript-eslint": typescriptPlugin };
66
+ var CLASS_MAP_SELECTOR = {
67
+ kind: "variable",
68
+ name: "^(?:[A-Z][A-Z0-9_]*_(?:STYLES|CLASSES)|[a-z][a-zA-Z0-9]*(?:Styles|Classes))$",
69
+ match: [{ type: "strings" }, { type: "objectValues" }]
70
+ };
71
+ var WORDS = {
72
+ app: {
73
+ ramp: "Tailwind's type ramp is not a token. Use the ramp from the package: text-body-2, text-caption, text-h3, text-btn-default...",
74
+ palette: "Tailwind's palette is not a token. Use a colour from the package (bg-bg-surface, text-text-primary, border-border-subtle...); a colour with no token is a missing token.",
75
+ raw: "A raw colour is a missing token: add it to the package (tokens.css in every theme block, and the preset), or say why not in an eslint-disable comment.",
76
+ opacity: "An opacity modifier on a token colour compiles to nothing. A transparent colour is a token of its own (D16): add it to the package.",
77
+ type: "$0 is type written by hand. A token from the package sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the package, or say why not in an eslint-disable comment.",
78
+ corner: "$0 is a corner written by hand, and no token has it. Use a corner from the package (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.",
79
+ shadow: "$0 is a shadow written by hand. Use a shadow from the package (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.",
80
+ merge: "Merge classes with the package's cn(), which the app re-exports. A plain tailwind-merge does not know the type ramp, and drops text-body-2 beside a text colour."
81
+ },
82
+ package: {
83
+ ramp: "Tailwind's type ramp is not a token. Use the ramp in tailwind-preset.js: text-body-2, text-caption, text-h3, text-btn-default...",
84
+ palette: "Tailwind's palette is not a token. Use a colour from tailwind-preset.js (bg-bg-surface, text-text-primary, border-border-subtle...); a colour with no token is a missing token.",
85
+ raw: "A raw colour is a missing token: add it to tokens.css in every theme block and to the preset, or say why not in an eslint-disable comment.",
86
+ opacity: "An opacity modifier on a token colour compiles to nothing. A transparent colour is a token of its own (D16): add it to tokens.css in every theme block and to the preset.",
87
+ type: "$0 is type written by hand. A token from tailwind-preset.js sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the preset and to cn.ts, or say why not in an eslint-disable comment.",
88
+ corner: "$0 is a corner written by hand, and no token has it. Use a corner from tailwind-preset.js (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.",
89
+ shadow: "$0 is a shadow written by hand. Use a shadow from tailwind-preset.js (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.",
90
+ merge: "Merge classes with this package's cn() (src/cn.ts), the one place tailwind-merge is taught the type ramp. A plain tailwind-merge drops text-body-2 beside a text colour."
91
+ }
92
+ };
93
+ function tokenLint({ tailwindConfig = "tailwind.config.js", audience = "app" } = {}) {
94
+ const words = WORDS[audience];
95
+ return {
96
+ files: ["**/*.{ts,tsx}"],
97
+ languageOptions: {
98
+ parser: typescriptParser,
99
+ parserOptions: { ecmaFeatures: { jsx: true } }
100
+ },
101
+ plugins: { "better-tailwindcss": betterTailwindcss },
102
+ settings: {
103
+ "better-tailwindcss": {
104
+ tailwindConfig,
105
+ selectors: [...getDefaultSelectors(), CLASS_MAP_SELECTOR]
106
+ }
107
+ },
108
+ rules: {
109
+ "better-tailwindcss/no-unknown-classes": "error",
110
+ "better-tailwindcss/no-restricted-classes": [
111
+ "error",
112
+ {
113
+ restrict: [
114
+ { pattern: "^(?:[a-z0-9-]+:)*text-(?:xs|sm|base|lg|xl|[2-9]xl)$", message: words.ramp },
115
+ {
116
+ pattern: "^(?:[a-z0-9-]+:)*(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|placeholder|from|via|to|accent|caret|shadow)-(?:black|white|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(?:-[0-9]{2,3})?(?:/[0-9]{1,3})?$",
117
+ message: words.palette
118
+ },
119
+ { pattern: "^(?:[a-z0-9-]+:)*[a-z-]+-\\[[^\\]]*(?:#[0-9a-fA-F]{3}|rgba?\\(|hsla?\\(|oklch\\()", message: words.raw },
120
+ {
121
+ pattern: "^(?:[a-z0-9-]+:)*(?:text|bg|border|ring|outline|fill|stroke|decoration|divide|placeholder|from|via|to|accent|caret|shadow)-(?:bg|text|border|accent|info|warning|success|error)-[a-z-]+/[0-9]{1,3}$",
122
+ message: words.opacity
123
+ }
124
+ ]
125
+ }
126
+ ],
127
+ // UIG-21: "use the package's cn(), never a plain twMerge" was a sentence
128
+ // in all three repos' CLAUDE.md, and nothing checked it.
129
+ "no-restricted-imports": ["error", { paths: [{ name: "tailwind-merge", message: words.merge }] }]
130
+ }
131
+ };
132
+ }
133
+ var V = "^(?:(?:[^:\\[\\]\\s]|\\[[^\\]]*\\])+:)*!?";
134
+ var NOT_COLOUR = "(?!color:|#|rgba?\\(|hsla?\\(|oklch\\(|var\\()";
135
+ var HAND_WRITTEN = {
136
+ type: `${V}(?:text-\\[${NOT_COLOUR}|text-\\[length:|leading-\\[|tracking-\\[)[^\\]]+\\](?:/\\S*)?$`,
137
+ corner: `${V}rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?-\\[[^\\]]+\\]$`,
138
+ shadow: `${V}(?:shadow|drop-shadow)-\\[(?!color:)[^\\]]+\\]$`,
139
+ spacing: `${V}-?(?:w|h|size|min-w|min-h|max-w|max-h|p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|basis|indent|scroll-[mp][xytrblse]?|border-spacing(?:-[xy])?)-\\[[^\\]]+\\]$`,
140
+ width: `${V}(?:border(?:-[xytrblse])?|divide-[xy]|outline|ring|ring-offset)-\\[(?:length:)?-?[0-9.]+(?:px|rem|em)?\\]$`
141
+ };
142
+ var INLINE_STYLE_TOKEN_PROPERTIES = String(INLINE_STYLE_TOKEN_KEYS);
143
+ function loadPreset() {
144
+ const require2 = createRequire(import.meta.url);
145
+ const loadConfig = require2("tailwindcss/loadConfig");
146
+ return loadConfig(fileURLToPath(new URL("../../tailwind-preset.js", import.meta.url)));
147
+ }
148
+ function tokenValues({ audience = "app" } = {}) {
149
+ const words = WORDS[audience];
150
+ const preset = loadPreset();
151
+ const tokensBySize = {};
152
+ for (const [name, value] of Object.entries(preset.theme.extend.fontSize)) {
153
+ const size = Array.isArray(value) ? value[0] : value;
154
+ (tokensBySize[size] ??= []).push(`text-${name}`);
155
+ }
156
+ const cornersBySize = {};
157
+ for (const [name, size] of Object.entries(preset.theme.extend.borderRadius)) (cornersBySize[size] ??= []).push(name === "DEFAULT" ? "rounded" : `rounded-${name}`);
158
+ return {
159
+ files: ["**/*.{ts,tsx}"],
160
+ ignores: ["**/*.test.ts", "**/*.test.tsx"],
161
+ plugins: { "token-values": betterTailwindcss, "token-spacing": betterTailwindcss, "token-style": tokenStylePlugin },
162
+ rules: {
163
+ // A style kept in a const and passed by name (R9): the selector below cannot follow it.
164
+ "token-style/no-token-style": "error",
165
+ "token-values/no-restricted-classes": [
166
+ "error",
167
+ {
168
+ restrict: [
169
+ ...Object.entries(tokensBySize).map(([size, names]) => ({
170
+ pattern: `${V}text-\\[${size.replace(".", "\\.")}\\](?:/\\S*)?$`,
171
+ message: `$0 is the type ramp written by hand. Use ${names.join(" or ")}.`
172
+ })),
173
+ {
174
+ pattern: HAND_WRITTEN.type,
175
+ message: words.type
176
+ },
177
+ ...Object.entries(cornersBySize).map(([size, names]) => ({
178
+ pattern: `${V}rounded(?:-(?:t|r|b|l|s|e|tl|tr|br|bl|ss|se|es|ee))?-\\[${size.replace(".", "\\.")}\\]$`,
179
+ message: `$0 is a corner written by hand. Use ${names.join(" or ")} (with the same side, if it has one).`
180
+ })),
181
+ {
182
+ pattern: HAND_WRITTEN.corner,
183
+ message: words.corner
184
+ },
185
+ {
186
+ pattern: HAND_WRITTEN.shadow,
187
+ message: words.shadow
188
+ }
189
+ ]
190
+ }
191
+ ],
192
+ "token-spacing/no-restricted-classes": [
193
+ "warn",
194
+ {
195
+ restrict: [
196
+ {
197
+ pattern: HAND_WRITTEN.spacing,
198
+ message: "$0 is a size or a space written by hand. Use a step of the spacing scale (p-3, h-9, gap-2...) if one fits. Reported, not blocked."
199
+ },
200
+ {
201
+ pattern: HAND_WRITTEN.width,
202
+ message: "$0 is a border or ring width written by hand. Use border, border-2, ring-1... if one fits. Reported, not blocked."
203
+ }
204
+ ]
205
+ }
206
+ ],
207
+ "no-restricted-syntax": [
208
+ "error",
209
+ {
210
+ selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.name=${INLINE_STYLE_TOKEN_PROPERTIES}]`,
211
+ message: INLINE_STYLE_MESSAGE
212
+ },
213
+ // The same key in quotes, `style={{ 'background': 'red' }}` (R9).
214
+ {
215
+ selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.value=${INLINE_STYLE_TOKEN_PROPERTIES}]`,
216
+ message: INLINE_STYLE_MESSAGE
217
+ }
218
+ ]
219
+ }
220
+ };
221
+ }
222
+ function tokenConfig({ quiet = {}, ignores = [], ...options } = {}) {
223
+ return [
224
+ { ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
225
+ {
226
+ plugins: { ...KNOWN_PLUGINS, ...quiet },
227
+ // Every other lint's directives are unused here, because their rules are
228
+ // off here. Reporting them would be this config complaining about the
229
+ // others' business.
230
+ linterOptions: { reportUnusedDisableDirectives: "off" }
231
+ },
232
+ tokenLint(options),
233
+ tokenValues({ audience: options.audience })
234
+ ];
235
+ }
236
+
237
+ // src/gates/ci.ts
238
+ import { spawnSync } from "node:child_process";
239
+ import { existsSync, readFileSync, statSync } from "node:fs";
240
+ import { join } from "node:path";
241
+ import { pathToFileURL } from "node:url";
242
+ var PROBE = '/** A probe CI writes to check the editor hook; never on disk. */\nexport function GateProbe() {\n return <button type="button">x</button>\n}\n';
243
+ var npm = (root, script) => spawnSync("npm", ["run", script], { cwd: root, stdio: "inherit", shell: true }).status === 0;
244
+ function countCommitted(root) {
245
+ const run = spawnSync("git", ["status", "--porcelain", "--", ".gates-count.json"], { cwd: root, encoding: "utf8" });
246
+ if (run.status !== 0) return [`git could not read .gates-count.json's state: ${run.stderr.trim()}`];
247
+ if (!run.stdout.trim()) return [];
248
+ const diff = spawnSync("git", ["diff", "--", ".gates-count.json"], { cwd: root, encoding: "utf8" }).stdout.trim();
249
+ return [
250
+ "The gate found something other than the committed count: run `npm run lint:rules` and commit `.gates-count.json` with the change that moved it.",
251
+ ...diff ? diff.split("\n").filter((l) => /^[+-]\s/.test(l)).map((l) => ` ${l}`) : [" .gates-count.json is not committed at all"]
252
+ ];
253
+ }
254
+ function hookRuns(root) {
255
+ const file = join(root, ".claude", "settings.json");
256
+ if (!existsSync(file)) return [".claude/settings.json is missing: the editor hook is not switched on (docs/GATES.md \xA723)."];
257
+ let entries;
258
+ try {
259
+ entries = JSON.parse(readFileSync(file, "utf8")).hooks?.PreToolUse ?? [];
260
+ } catch (error) {
261
+ return [`.claude/settings.json does not read as JSON: ${error instanceof Error ? error.message : String(error)}`];
262
+ }
263
+ const covers = (matcher, tool) => {
264
+ if (!matcher || matcher === "*") return true;
265
+ try {
266
+ return new RegExp(`^(?:${matcher})$`).test(tool);
267
+ } catch {
268
+ return false;
269
+ }
270
+ };
271
+ const input = JSON.stringify({ tool_name: "Write", session_id: "estiva-gates-ci", cwd: root, tool_input: { file_path: join(root, "src", "GateProbe.tsx"), content: PROBE } });
272
+ const tried = [];
273
+ for (const entry of entries) {
274
+ if (!covers(entry.matcher, "Write") || !covers(entry.matcher, "Edit")) continue;
275
+ for (const hook of entry.hooks ?? []) {
276
+ if (hook.type !== "command" || !hook.command) continue;
277
+ const command = hook.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, root.split("\\").join("/"));
278
+ const run = spawnSync(command, { cwd: root, input, encoding: "utf8", shell: true, env: { ...process.env, CLAUDE_PROJECT_DIR: root }, timeout: 12e4 });
279
+ if (run.status === 2 && /UI Guardrails/.test(run.stderr)) return [];
280
+ tried.push(` ${hook.command} \u2192 exit ${run.status ?? "none"}${run.stderr.trim() ? `: ${run.stderr.trim().split("\n")[0]}` : ""}`);
281
+ }
282
+ }
283
+ return [
284
+ tried.length ? "The editor hook let a raw button through: no PreToolUse hook in .claude/settings.json refused it." : "No PreToolUse hook in .claude/settings.json runs before both Write and Edit: the editor gate is off.",
285
+ ...tried
286
+ ];
287
+ }
288
+ var plain = (glob) => glob.replace(/\/\*\*.*$|\/$/, "");
289
+ async function skippedFolders(root) {
290
+ const found = /* @__PURE__ */ new Set();
291
+ for (const name of ["eslint.gates.config.js", "eslint.tokens.config.js"]) {
292
+ const file = join(root, name);
293
+ if (!existsSync(file)) continue;
294
+ const loaded = await import(`${pathToFileURL(file).href}?at=${statSync(file).mtimeMs}`);
295
+ const configs = [loaded.default].flat(Infinity);
296
+ for (const c of configs) {
297
+ if (!c || typeof c !== "object" || !Array.isArray(c.ignores) || Object.keys(c).some((k) => k !== "ignores" && k !== "name")) continue;
298
+ for (const glob of c.ignores) if (!TOKEN_LINT_IGNORES.includes(glob)) found.add(glob);
299
+ }
300
+ }
301
+ return [...found];
302
+ }
303
+ async function skipsListed(root, audience) {
304
+ const page = audience === "package" ? "docs/GATES.md" : "docs/GATES-DEBT.md";
305
+ const skipped = await skippedFolders(root);
306
+ if (!skipped.length) return [];
307
+ const text = existsSync(join(root, page)) ? readFileSync(join(root, page), "utf8") : "";
308
+ const missing = skipped.filter((glob) => !text.includes(plain(glob)));
309
+ if (!missing.length) return [];
310
+ return [`The gate skips folders ${page} does not name. Name each there, with why it is not checked (B11):`, ...missing.map((g) => ` ${g}`)];
311
+ }
312
+ async function runCi({ root = process.cwd(), audience = "app", print = (line) => process.stdout.write(`${line}
313
+ `) } = {}) {
314
+ const scripts = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scripts ?? {};
315
+ const tokens = scripts["lint:tokens"] ? "lint:tokens" : "lint";
316
+ const failed = [];
317
+ const step = async (title, run) => {
318
+ print(`
319
+ \u25B8 ${title}`);
320
+ const result = await run();
321
+ const problems = result === true ? [] : result === false ? [`${title}: failed (above)`] : result;
322
+ if (problems.length) {
323
+ failed.push(title);
324
+ for (const line of problems) print(process.env.GITHUB_ACTIONS && !line.startsWith(" ") ? `::error::${line}` : line);
325
+ } else print(" \u2713");
326
+ };
327
+ await step("The gate lint (npm run lint:rules)", () => npm(root, "lint:rules"));
328
+ await step("The count is committed (.gates-count.json)", () => countCommitted(root));
329
+ await step(`The token contract (npm run ${tokens})`, () => npm(root, tokens));
330
+ await step("The catalogue (npm run registry:check)", () => npm(root, "registry:check"));
331
+ await step("The editor hook refuses a raw button (.claude/settings.json)", () => hookRuns(root));
332
+ await step("Every folder the gate skips is named on the debt page", () => skipsListed(root, audience));
333
+ print(failed.length ? `
334
+ The gate failed: ${failed.join("; ")}.` : "\nThe gate passed.");
335
+ return failed.length ? 1 : 0;
336
+ }
337
+
338
+ // src/gates/count.ts
339
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "node:fs";
340
+ import { createRequire as createRequire2 } from "node:module";
341
+ import { join as join2, relative } from "node:path";
342
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
343
+ import { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS } from "../eslint/index.js";
344
+ async function eslintFrom(root) {
345
+ const resolved = createRequire2(join2(root, "package.json")).resolve("eslint");
346
+ return (await import(pathToFileURL2(resolved).href)).ESLint;
347
+ }
348
+ async function writeGateCount({ root = process.cwd(), repo, audience = "app", config = "eslint.gates.config.js" }) {
349
+ const out = join2(root, ".gates-count.json");
350
+ const ESLint = await eslintFrom(root);
351
+ const eslint = new ESLint({
352
+ cwd: root,
353
+ overrideConfigFile: join2(root, config),
354
+ overrideConfig: { settings: { estiva: { reportEscapes: true } } }
355
+ });
356
+ const { rules, disabled } = countGates(await eslint.lintFiles(["."]), audience === "package" ? PACKAGE_RULE_IDS : APP_RULE_IDS);
357
+ const previous = existsSync2(out) ? JSON.parse(readFileSync2(out, "utf8")) : null;
358
+ const changed = JSON.stringify(previous?.rules) !== JSON.stringify(rules);
359
+ if (changed) {
360
+ const count = { schemaVersion: 1, repo, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), rules };
361
+ writeFileSync(out, `${JSON.stringify(count, null, 2)}
362
+ `);
363
+ }
364
+ const lines = [changed ? ".gates-count.json: written" : ".gates-count.json: unchanged"];
365
+ for (const [id, c] of Object.entries(rules)) lines.push(` ${id}: ${c.errors} errors, ${c.warnings} warnings, ${c.escapes} escapes`);
366
+ const failures = [];
367
+ if (disabled.length > 0) {
368
+ failures.push("", "An eslint-disable switched a gate rule off. Write the reason above the element instead, naming the rule: // @estiva-escape(<rule>): <reason>");
369
+ for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`);
370
+ }
371
+ return { rules, disabled, changed, lines, failures };
372
+ }
373
+
374
+ // src/gates/hook.ts
375
+ import { spawnSync as spawnSync2 } from "node:child_process";
376
+ import { createHash } from "node:crypto";
377
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
378
+ import { createRequire as createRequire3 } from "node:module";
379
+ import { tmpdir } from "node:os";
380
+ import { basename, isAbsolute, join as join3, relative as relative2, resolve, sep } from "node:path";
381
+ import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL3 } from "node:url";
382
+ import { countGates as countGates2 } from "../eslint/index.js";
383
+ var TOKEN_RULE_IDS = new Set(tokenConfig().flatMap((c) => Object.keys(c.rules ?? {})));
384
+ async function tokenErrors(ESLint, appDir, file, text, audience) {
385
+ const own = join3(appDir, "eslint.tokens.config.js");
386
+ const eslint = existsSync3(own) ? new ESLint({ cwd: appDir, overrideConfigFile: own }) : new ESLint({ cwd: appDir, overrideConfigFile: true, overrideConfig: tokenConfig({ audience }) });
387
+ const results = await eslint.lintText(text, { filePath: file });
388
+ return results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId && TOKEN_RULE_IDS.has(m.ruleId)));
389
+ }
390
+ var PASS = { code: 0, message: "" };
391
+ var APP_SOURCE = /^src\/.+\.[cm]?[jt]sx?$/;
392
+ async function runHook({ root, app = ".", audience = "app", input, seen } = {}) {
393
+ let call;
394
+ try {
395
+ call = input ?? JSON.parse(readFileSync3(0, "utf8"));
396
+ } catch {
397
+ return PASS;
398
+ }
399
+ const args = call.tool_input ?? {};
400
+ if (typeof args.file_path !== "string") return PASS;
401
+ const top = root ?? process.env.CLAUDE_PROJECT_DIR ?? call.cwd ?? process.cwd();
402
+ const appDir = resolve(top, app);
403
+ const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path);
404
+ const rel = relative2(appDir, file).split(sep).join("/");
405
+ const source = audience === "package" ? /^src\/.+\.tsx$/ : APP_SOURCE;
406
+ if (rel.startsWith("../") || isAbsolute(rel) || !source.test(rel) || /\.test\.[cm]?[jt]sx?$/.test(rel) || /\.d\.[cm]?ts$/.test(rel)) return PASS;
407
+ const text = proposed(call.tool_name, args, file);
408
+ if (text === null) return PASS;
409
+ const resolved = createRequire3(join3(appDir, "package.json")).resolve("eslint");
410
+ const { ESLint } = await import(pathToFileURL3(resolved).href);
411
+ const config = join3(appDir, "eslint.gates.config.js");
412
+ const eslint = new ESLint({ cwd: appDir, overrideConfigFile: config });
413
+ const results = await eslint.lintText(text, { filePath: file });
414
+ const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId));
415
+ errors.push(...await tokenErrors(ESLint, appDir, file, text, audience));
416
+ const { disabled } = countGates2(results);
417
+ if (errors.length === 0 && disabled.length === 0) {
418
+ const stopped = searchFirst(call, file, text, appDir, top, seen);
419
+ if (stopped) return stopped;
420
+ const copies = results.flatMap((r) => r.messages.filter((m) => m.severity === 1 && m.ruleId === "estiva/no-copied-look"));
421
+ return copies.length ? { code: 0, message: "", note: copies.map((m) => `${relative2(top, file).split(sep).join("/")}:${m.line} ${m.message}`).join("\n") } : PASS;
422
+ }
423
+ const shown = (p) => relative2(top, p).split(sep).join("/");
424
+ const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`];
425
+ for (const m of errors) lines.push(` ${m.line}:${m.column} ${m.message}${m.ruleId ? ` (${m.ruleId})` : ""}`);
426
+ const keep = audience === "package" ? "Keep it only" : "Keep the element only";
427
+ 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(${d.ruleId.slice(d.ruleId.lastIndexOf("/") + 1)}): <reason>`);
428
+ return { code: 2, message: `${lines.join("\n")}
429
+ ` };
430
+ }
431
+ function newPart(tool, file, text) {
432
+ if (tool !== "Write" || existsSync3(file) || !file.endsWith(".tsx") || /\.stories\.tsx$/.test(file)) return null;
433
+ if (!/<\/[A-Za-z]|\/>/.test(text)) return null;
434
+ const named = /export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class)\s+([A-Z][A-Za-z0-9]*)/.exec(text)?.[1];
435
+ if (named) return named;
436
+ return /export\s+default\s+function\s*\(/.test(text) ? basename(file, ".tsx") : null;
437
+ }
438
+ var wordsOf = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").toLowerCase();
439
+ function findCommand() {
440
+ for (const at of ["../registry/cli.js", "../../dist/registry/cli.js"]) {
441
+ const path = fileURLToPath2(new URL(at, import.meta.url));
442
+ if (existsSync3(path)) return path;
443
+ }
444
+ return null;
445
+ }
446
+ function searchFirst(call, file, text, appDir, top, seen) {
447
+ const part = newPart(call.tool_name, file, text);
448
+ const command = findCommand();
449
+ if (!part || !command) return null;
450
+ const folder = seen ?? join3(tmpdir(), "estiva-ui-search-first");
451
+ const mark = join3(folder, createHash("sha1").update(`${call.session_id ?? ""}
452
+ ${resolve(file)}`).digest("hex"));
453
+ if (existsSync3(mark)) return null;
454
+ const words = wordsOf(part);
455
+ const repo = basename(repositoryOf(appDir));
456
+ const run = spawnSync2(process.execPath, [command, "find", ...words.split(" "), "--root", appDir, "--repo", repo, "--limit", "5"], { encoding: "utf8", timeout: 6e4 });
457
+ if (run.status !== 0 || !run.stdout.trim()) return null;
458
+ try {
459
+ mkdirSync(folder, { recursive: true });
460
+ writeFileSync2(mark, "");
461
+ } catch {
462
+ return null;
463
+ }
464
+ const shown = relative2(top, file).split(sep).join("/");
465
+ return {
466
+ code: 2,
467
+ message: `${shown} was not written yet: it adds a new part, ${part}. Search before building (UIG-20). What already exists for "${words}":
468
+
469
+ ${run.stdout.trim()}
470
+
471
+ 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.
472
+ `
473
+ };
474
+ }
475
+ function proposed(tool, args, file) {
476
+ if (tool === "Write") return typeof args.content === "string" ? args.content : null;
477
+ if (tool !== "Edit" || typeof args.old_string !== "string" || typeof args.new_string !== "string") return null;
478
+ const { old_string: from, new_string: to } = args;
479
+ const current = existsSync3(file) ? readFileSync3(file, "utf8") : "";
480
+ for (const text of [current, current.replace(/\r\n/g, "\n")]) {
481
+ if (!text.includes(from)) continue;
482
+ return args.replace_all === true ? text.split(from).join(to) : text.replace(from, () => to);
483
+ }
484
+ return null;
485
+ }
486
+
487
+ // src/gates/name-escapes.ts
488
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
489
+ import { createRequire as createRequire4 } from "node:module";
490
+ import { join as join4, relative as relative3 } from "node:path";
491
+ import { pathToFileURL as pathToFileURL4 } from "node:url";
492
+ var UNNAMED = /@estiva-escape(?!\s*\()/;
493
+ var NAMED_IN_MESSAGE = /@estiva-escape\(([^)]+)\)/;
494
+ async function nameEscapes({ root = process.cwd(), config = "eslint.gates.config.js" } = {}) {
495
+ const resolved = createRequire4(join4(root, "package.json")).resolve("eslint");
496
+ const { ESLint } = await import(pathToFileURL4(resolved).href);
497
+ const eslint = new ESLint({ cwd: root, overrideConfigFile: join4(root, config) });
498
+ const results = await eslint.lintFiles(["."]);
499
+ const named = [];
500
+ const unreached = [];
501
+ for (const result of results) {
502
+ const byLine = /* @__PURE__ */ new Map();
503
+ for (const m of result.messages) {
504
+ if (m.messageId !== "escapeWithoutRule") continue;
505
+ const rule = NAMED_IN_MESSAGE.exec(m.message)?.[1];
506
+ if (!rule) continue;
507
+ byLine.set(m.line, (byLine.get(m.line) ?? /* @__PURE__ */ new Set()).add(rule));
508
+ }
509
+ const source = result.source ?? readFileSync4(result.filePath, "utf8");
510
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
511
+ const lines = source.split(/\r?\n/);
512
+ let changed = false;
513
+ const file = relative3(root, result.filePath).split("\\").join("/");
514
+ for (const [line, rules] of byLine) {
515
+ for (let i = line - 1; i < Math.min(lines.length, line + 3); i++) {
516
+ if (!UNNAMED.test(lines[i])) continue;
517
+ const names = [...rules].sort().join(", ");
518
+ lines[i] = lines[i].replace(UNNAMED, `@estiva-escape(${names})`);
519
+ named.push(`${file}:${i + 1} ${names}`);
520
+ changed = true;
521
+ break;
522
+ }
523
+ }
524
+ if (changed) writeFileSync3(result.filePath, lines.join(eol), "utf8");
525
+ lines.forEach((text, i) => {
526
+ if (UNNAMED.test(text) && !/eslint-disable/.test(text) && /\/\/|\/\*/.test(text)) unreached.push(`${file}:${i + 1}`);
527
+ });
528
+ }
529
+ return { named, unreached };
530
+ }
531
+
532
+ // src/gates/escapes.ts
533
+ import { execFileSync } from "node:child_process";
534
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
535
+ import { join as join5, relative as relative4 } from "node:path";
536
+
537
+ // src/eslint/escape.ts
538
+ var ESCAPE_MARKER = "@estiva-escape";
539
+ var MIN_REASON = 10;
540
+ var ESCAPE_MESSAGES = {
541
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}({{rule}}): <why this stays>\`. An escape with no reason hides nothing.`,
542
+ escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}({{rule}}): <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
543
+ escapeWithoutRule: `Name the rule this escape keeps off: \`${ESCAPE_MARKER}({{rule}}): <reason>\`. An escape now keeps off only the rules it names.`,
544
+ escaped: "Escaped: {{reason}}"
545
+ };
546
+ var shortRuleName = (id) => id.slice(id.lastIndexOf("/") + 1);
547
+ function readMarker(text) {
548
+ const at = text.indexOf(ESCAPE_MARKER);
549
+ if (at === -1) return null;
550
+ const rest = text.slice(at + ESCAPE_MARKER.length);
551
+ const named = /^\s*\(([^)]*)\)/.exec(rest);
552
+ const rules = named ? named[1].split(/[\s,]+/).filter(Boolean).map(shortRuleName) : [];
553
+ const reason = (named ? rest.slice(named[0].length) : rest).replace(/^\s*:/, "").trim();
554
+ return { rules, reason };
555
+ }
556
+
557
+ // src/gates/escapes.ts
558
+ var REVIEW_AFTER_DAYS = 30;
559
+ var SOURCE = /\.[cm]?[jt]sx?$/;
560
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git", "storybook-static", "coverage"]);
561
+ var DIRECTIVE = /eslint-disable(?:-next-line|-line)?\s+(.*?)\s+--\s+@estiva-escape\s*:?\s*(.*)$/;
562
+ function sourceFiles(dir, out = []) {
563
+ if (!existsSync4(dir)) return out;
564
+ for (const name of readdirSync(dir)) {
565
+ if (SKIP.has(name)) continue;
566
+ const path = join5(dir, name);
567
+ if (statSync2(path).isDirectory()) sourceFiles(path, out);
568
+ else if (SOURCE.test(name) && !/\.test\.[cm]?[jt]sx?$/.test(name)) out.push(path);
569
+ }
570
+ return out;
571
+ }
572
+ function lineTimes(root, file) {
573
+ const times = /* @__PURE__ */ new Map();
574
+ try {
575
+ const out = execFileSync("git", ["blame", "--line-porcelain", "--", relative4(root, file)], { cwd: root, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 << 20 }).toString();
576
+ let line = 0;
577
+ for (const row of out.split("\n")) {
578
+ const header = /^[0-9a-f]{40} \d+ (\d+)/.exec(row);
579
+ if (header) line = Number(header[1]);
580
+ else if (row.startsWith("author-time ")) times.set(line, Number(row.slice(12)));
581
+ }
582
+ } catch {
583
+ }
584
+ return times;
585
+ }
586
+ function listEscapes(root, { folders = ["src"], now = Date.now() } = {}) {
587
+ const found = [];
588
+ for (const file of folders.flatMap((f) => sourceFiles(join5(root, f)))) {
589
+ const lines = readFileSync5(file, "utf8").split(/\r?\n/);
590
+ const hits = [];
591
+ lines.forEach((text, i) => {
592
+ if (!text.includes("@estiva-escape")) return;
593
+ const rel = relative4(root, file).split("\\").join("/");
594
+ if (/<(reason|why|rule)\b/.test(text)) return;
595
+ const directive = /^\s*(?:\/\/|\{?\/\*)\s*eslint-disable/.test(text) ? DIRECTIVE.exec(text) : null;
596
+ if (directive) {
597
+ hits.push({ file: rel, line: i + 1, rules: directive[1].split(/[\s,]+/).filter(Boolean), reason: directive[2].replace(/\*\/\s*}?\s*$/, "").trim(), kind: "token" });
598
+ return;
599
+ }
600
+ if (!/^\s*(?:\/\/+|\{?\/\*+)?\s*@estiva-escape(?:\s*\(|\s*:)/.test(text)) return;
601
+ const marker = readMarker(text);
602
+ if (!marker) return;
603
+ hits.push({ file: rel, line: i + 1, rules: marker.rules, reason: marker.reason.replace(/\*\/\s*}?\s*$/, "").trim(), kind: "gate" });
604
+ });
605
+ if (!hits.length) continue;
606
+ const times = lineTimes(root, file);
607
+ for (const hit of hits) {
608
+ const at = times.get(hit.line);
609
+ found.push({ ...hit, days: at === void 0 ? null : Math.floor((now - at * 1e3) / 864e5) });
610
+ }
611
+ }
612
+ return found.sort((a, b) => (b.days ?? -1) - (a.days ?? -1) || a.file.localeCompare(b.file) || a.line - b.line);
613
+ }
614
+ function escapeLines(escapes, { all = false } = {}) {
615
+ const due = escapes.filter((e) => (e.days ?? 0) > REVIEW_AFTER_DAYS);
616
+ const shown = all ? escapes : due;
617
+ const gate = escapes.filter((e) => e.kind === "gate").length;
618
+ const out = [`Escapes: ${escapes.length} (${gate} of the gate's, ${escapes.length - gate} of the token lint's) \xB7 ${due.length} older than ${REVIEW_AFTER_DAYS} days, to review: keep with a new reason, or fix${all ? "" : " \xB7 every escape: add -- --escapes"}`];
619
+ for (const e of shown) out.push(` ${e.file}:${e.line} ${e.rules.join(", ") || "(no rule named)"} \xB7 ${e.days === null ? "not in git yet" : `${e.days} days`}
620
+ ${e.reason}`);
621
+ return out;
622
+ }
623
+
624
+ // src/gates/status.ts
625
+ import { execFileSync as execFileSync2, spawnSync as spawnSync3 } from "node:child_process";
626
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync6, statSync as statSync3 } from "node:fs";
627
+ import { createRequire as createRequire5 } from "node:module";
628
+ import { join as join6, relative as relative5, resolve as resolve2 } from "node:path";
629
+ import { pathToFileURL as pathToFileURL5 } from "node:url";
630
+ var { version } = createRequire5(import.meta.url)("../../package.json");
631
+ var ENGINE = `@estiva-app/ui@${version}`;
632
+ var PASS2 = (detail) => ({ result: "pass", detail });
633
+ var FAIL = (detail) => ({ result: "fail", detail });
634
+ var PART = (detail) => ({ result: "part", detail });
635
+ var UNKNOWN = (detail) => ({ result: "unknown", detail });
636
+ var said = (e) => {
637
+ const err = e;
638
+ return { text: `${err.stdout ?? ""}${err.stderr ?? ""}`, message: String(err.message ?? e), code: err.code };
639
+ };
640
+ var GATE_CI_SCRIPTS = /* @__PURE__ */ new Set(["lint:rules", "lint:tokens", "lint", "registry:check"]);
641
+ function runsGateCi(text, script) {
642
+ if (typeof script !== "string" || !GATE_CI_SCRIPTS.has(script)) return null;
643
+ const m = /^(?!\s*#).*?((?:npx )?estiva-gates ci\b|npm run gates:ci\b|cli\.js"? ci\b)/m.exec(text);
644
+ return m ? m[1] : null;
645
+ }
646
+ function helpers(ROOT) {
647
+ const abs = (rel) => resolve2(ROOT, rel);
648
+ const exists = (rel) => existsSync5(abs(rel));
649
+ const read = (rel) => readFileSync6(abs(rel), "utf8");
650
+ const git = (...a) => {
651
+ try {
652
+ return execFileSync2("git", a, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
653
+ } catch {
654
+ return null;
655
+ }
656
+ };
657
+ const listFiles = (rel, test) => {
658
+ const out = [];
659
+ const walk = (dir) => {
660
+ if (!existsSync5(dir)) return;
661
+ for (const name of readdirSync2(dir)) {
662
+ if (["node_modules", ".git", "dist", "storybook-static", ".verify-shots"].includes(name)) continue;
663
+ const p = join6(dir, name);
664
+ if (statSync3(p).isDirectory()) walk(p);
665
+ else if (test(name, p)) out.push(relative5(ROOT, p).replace(/\\/g, "/"));
666
+ }
667
+ };
668
+ walk(abs(rel));
669
+ return out;
670
+ };
671
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
672
+ return {
673
+ PASS: PASS2,
674
+ FAIL,
675
+ PART,
676
+ UNKNOWN,
677
+ exists,
678
+ read,
679
+ listFiles,
680
+ // The registry bundle's own builder, loaded only when a check asks for it:
681
+ // it reads TypeScript with TypeScript, which the other checks never need.
682
+ async catalogue(appDir) {
683
+ try {
684
+ const { buildAppRegistry, validateRegistry } = await import("../registry/index.js");
685
+ const registry = buildAppRegistry({ root: abs(appDir) });
686
+ const problems = validateRegistry(registry);
687
+ if (problems.length) return FAIL(`${problems.length} problems, the first: ${problems[0]}`);
688
+ return PASS2(`${registry.entries.length} parts in ${registry.builtFrom.files} files, each described and sorted`);
689
+ } catch (error) {
690
+ const lines = String(error instanceof Error ? error.message : error).split("\n");
691
+ return FAIL(lines.length > 1 ? `${lines.length - 1} problems, the first: ${lines[1].trim()}` : lines[0]);
692
+ }
693
+ },
694
+ async contract(appDir) {
695
+ try {
696
+ const { buildAppRegistry, contractProblems, CONTRACT_KINDS } = await import("../registry/index.js");
697
+ const registry = buildAppRegistry({ root: abs(appDir) });
698
+ const problems = contractProblems(registry, abs(appDir));
699
+ const owed = registry.entries.filter((entry) => CONTRACT_KINDS.includes(entry.app?.class ?? "")).length;
700
+ if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`);
701
+ return PASS2(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`);
702
+ } catch (error) {
703
+ return FAIL(String(error instanceof Error ? error.message : error).split("\n")[0]);
704
+ }
705
+ },
706
+ instructions(limit = 200) {
707
+ if (!exists("CLAUDE.md")) return FAIL("there is no CLAUDE.md");
708
+ const lines = (rel) => read(rel).replace(/\r?\n$/, "").split(/\r?\n/).length;
709
+ const imports = [...read("CLAUDE.md").matchAll(/^@(\S+)\s*$/gm)].map((m) => m[1]);
710
+ const missing = imports.filter((rel) => !exists(rel));
711
+ if (missing.length) return FAIL(`CLAUDE.md imports ${missing.join(", ")}, which is not there`);
712
+ const total = imports.reduce((n, rel) => n + lines(rel), lines("CLAUDE.md"));
713
+ const what = imports.length ? `CLAUDE.md with ${imports.join(", ")}` : "CLAUDE.md";
714
+ if (total >= limit) return FAIL(`${what}: ${total} lines load into every session; the limit is ${limit}`);
715
+ const rules = listFiles(".claude/rules", (n) => n.endsWith(".md"));
716
+ const unscoped = rules.filter((rel) => {
717
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(read(rel));
718
+ return !front || !/^paths:/m.test(front[1]);
719
+ });
720
+ if (unscoped.length) return FAIL(`${unscoped.join(", ")} has no paths:, so it loads into every session`);
721
+ return PASS2(`${what}: ${total} lines in every session; ${rules.length ? `${rules.length} rule files, each loading only for its paths` : "no rule files"}`);
722
+ },
723
+ file(rel) {
724
+ return exists(rel) ? PASS2(`${rel} exists`) : FAIL(`${rel} does not exist`);
725
+ },
726
+ committed(rel) {
727
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
728
+ return git("ls-files", "--error-unmatch", rel) ? PASS2(`${rel} is committed`) : FAIL(`${rel} exists but is not committed`);
729
+ },
730
+ contains(rel, needle, label) {
731
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
732
+ const hit = typeof needle === "string" ? read(rel).includes(needle) : needle.test(read(rel));
733
+ return hit ? PASS2(label ?? `${rel} has ${needle}`) : FAIL(label ? `not yet: ${label}` : `${rel} has no ${needle}`);
734
+ },
735
+ lacks(rel, needle, label) {
736
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
737
+ const hit = typeof needle === "string" ? read(rel).includes(needle) : needle.test(read(rel));
738
+ return hit ? FAIL(`${rel} still has ${needle}`) : PASS2(label ?? `${rel} has no ${needle}`);
739
+ },
740
+ script(pkgRel, name) {
741
+ if (!exists(pkgRel)) return FAIL(`${pkgRel} does not exist`);
742
+ const cmd = JSON.parse(read(pkgRel)).scripts?.[name];
743
+ return cmd ? PASS2(`${pkgRel} runs "${name}": ${cmd}`) : FAIL(`${pkgRel} has no "${name}" script`);
744
+ },
745
+ async loads(rel) {
746
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
747
+ try {
748
+ const mod = await import(pathToFileURL5(abs(rel)).href);
749
+ return mod.default ? PASS2(`${rel} loads`) : FAIL(`${rel} has no default export`);
750
+ } catch (e) {
751
+ return FAIL(`${rel} does not load: ${said(e).message.split("\n")[0]}`);
752
+ }
753
+ },
754
+ /** A workflow step that runs `npm run <script>`; a regex matches the script name. */
755
+ ci(script) {
756
+ const files = listFiles(".github/workflows", (n) => /\.ya?ml$/.test(n));
757
+ const name = typeof script === "string" ? escape(script) : script.source;
758
+ const step = new RegExp(`npm run (${name})(?![\\w:-])`);
759
+ for (const f of files) {
760
+ const m = read(f).match(step);
761
+ if (m) return PASS2(`${f} runs npm run ${m[1]}`);
762
+ const all = runsGateCi(read(f), script);
763
+ if (all) return PASS2(`${f} runs ${all}, which runs npm run ${script}`);
764
+ }
765
+ return FAIL(`no workflow runs npm run ${script}`);
766
+ },
767
+ /**
768
+ * A workflow job, by its id, with a step that runs `npm run <script>`. Branch
769
+ * protection requires a whole job by its name, so a required check needs the
770
+ * job, not the step somewhere (UIG-6). A job is a key two spaces in under
771
+ * `jobs:`, and ends at the next line that is not indented further; a comment
772
+ * line does not count.
773
+ */
774
+ ciJob(job, script) {
775
+ const files = listFiles(".github/workflows", (n) => /\.ya?ml$/.test(n));
776
+ const header = new RegExp(`^ ${escape(job)}:\\s*(#.*)?$`);
777
+ const step = new RegExp(`^(?!\\s*#).*npm run ${escape(script)}(?![\\w:-])`, "m");
778
+ for (const f of files) {
779
+ const lines = read(f).split(/\r?\n/);
780
+ const from = lines.findIndex((l) => /^jobs:\s*$/.test(l));
781
+ const start = from === -1 ? -1 : lines.findIndex((l, i) => i > from && header.test(l));
782
+ if (start === -1) continue;
783
+ const end = lines.findIndex((l, i) => i > start && /^ {0,2}\S/.test(l));
784
+ const body = lines.slice(start + 1, end === -1 ? void 0 : end).join("\n");
785
+ if (step.test(body)) return PASS2(`${f}: the job ${job} runs npm run ${script}`);
786
+ const all = runsGateCi(body, script);
787
+ return all ? PASS2(`${f}: the job ${job} runs ${all}, which runs npm run ${script}`) : FAIL(`${f}: the job ${job} does not run npm run ${script}`);
788
+ }
789
+ return FAIL(`no workflow has a job ${job}`);
790
+ },
791
+ hook(settingsRel, needle) {
792
+ if (!exists(settingsRel)) return FAIL(`${settingsRel} does not exist`);
793
+ let s;
794
+ try {
795
+ s = JSON.parse(read(settingsRel));
796
+ } catch {
797
+ return FAIL(`${settingsRel} is not valid JSON`);
798
+ }
799
+ const entries = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => ({ matcher: m.matcher ?? "", command: x.command ?? "" })));
800
+ const hit = entries.find((e) => e.command.includes(needle));
801
+ if (!hit) return FAIL(entries.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`);
802
+ let matches;
803
+ try {
804
+ matches = hit.matcher === "" || hit.matcher === "*" || new RegExp(`^(?:${hit.matcher})$`).test("Write");
805
+ } catch {
806
+ return FAIL(`${settingsRel}: the hook's matcher "${hit.matcher}" is not a pattern`);
807
+ }
808
+ if (!matches) return FAIL(`${settingsRel}: the hook runs only for "${hit.matcher}", never for a Write`);
809
+ const top = ROOT.split("\\").join("/");
810
+ const app = /--app\s+("?)([^"\s]+)\1/.exec(hit.command)?.[2] ?? ".";
811
+ const input = JSON.stringify({
812
+ hook_event_name: "PreToolUse",
813
+ cwd: top,
814
+ tool_name: "Write",
815
+ 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' }
816
+ });
817
+ const command = hit.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, top);
818
+ const run = spawnSync3(command, { cwd: ROOT, input, shell: true, encoding: "utf8", timeout: 12e4, env: { ...process.env, CLAUDE_PROJECT_DIR: top } });
819
+ if (run.status === 2 && /<button>/.test(`${run.stderr ?? ""}${run.stdout ?? ""}`)) return PASS2(`${settingsRel}: the hook ran and refused a raw <button> (exit 2)`);
820
+ const why = `${run.stderr ?? ""}${run.stdout ?? ""}`.trim().split(/\r?\n/)[0] || (run.error ? String(run.error.message) : "no output");
821
+ return FAIL(`${settingsRel}: the hook ran and let a raw <button> through (exit ${run.status ?? "none"}): ${why}`);
822
+ },
823
+ json(rel, test = () => true, label) {
824
+ if (!exists(rel)) return FAIL(`${rel} does not exist`);
825
+ let data;
826
+ try {
827
+ data = JSON.parse(read(rel));
828
+ } catch {
829
+ return FAIL(`${rel} is not valid JSON`);
830
+ }
831
+ if (!git("ls-files", "--error-unmatch", rel)) return FAIL(`${rel} is not committed`);
832
+ return test(data) ? PASS2(label ?? `${rel} is committed and parses`) : FAIL(`${rel} parses, but ${label ?? "fails its test"}`);
833
+ },
834
+ /** Lints a line of code that is never written to disk, the way the hook will. */
835
+ async lint({ cwd = ".", config, file, code, expect, mentions }) {
836
+ const cfg = join6(cwd, config);
837
+ if (!exists(cfg)) return FAIL(`${cfg} does not exist`);
838
+ let ESLint;
839
+ try {
840
+ const req = createRequire5(join6(abs(cwd), "package.json"));
841
+ ({ ESLint } = await import(pathToFileURL5(req.resolve("eslint")).href));
842
+ } catch (e) {
843
+ return UNKNOWN(`could not load eslint from ${cwd}: ${said(e).message.split("\n")[0]}`);
844
+ }
845
+ let results;
846
+ try {
847
+ const eslint = new ESLint({ cwd: abs(cwd), overrideConfigFile: config });
848
+ results = await eslint.lintText(code, { filePath: join6(abs(cwd), file), warnIgnored: true });
849
+ } catch (e) {
850
+ return UNKNOWN(`${cfg} could not lint ${file}: ${said(e).message.split("\n")[0]}`);
851
+ }
852
+ const messages = results.flatMap((r) => r.messages);
853
+ const ignored = messages.some((m) => /ignored/i.test(m.message) && !m.ruleId);
854
+ const fatal = messages.find((m) => m.fatal && !/ignored/i.test(m.message));
855
+ if (fatal) return UNKNOWN(`${file} did not parse: ${fatal.message}`);
856
+ const want = expect === "error" ? 2 : expect === "warning" ? 1 : 0;
857
+ if (want === 0) {
858
+ const any = messages.filter((m) => m.ruleId && m.severity === 2);
859
+ return any.length ? FAIL(`${file} gets an error it should not: ${any[0].message}`) : PASS2(`${file}: no error${ignored ? " (not linted)" : ""}`);
860
+ }
861
+ if (ignored) return FAIL(`${cfg} does not lint ${file}`);
862
+ const hit = messages.find((m) => m.ruleId && m.severity === want && (!mentions || m.message.includes(mentions)));
863
+ const kind = want === 2 ? "error" : "warning";
864
+ return hit ? PASS2(`${file} gets ${want === 2 ? "an" : "a"} ${kind}: ${hit.message}`) : FAIL(`${file} gets no ${kind}${mentions ? ` naming ${mentions}` : ""}`);
865
+ },
866
+ /**
867
+ * A GitHub setting, not a file, so it is asked of GitHub: the checks a merge
868
+ * into the default branch must pass, from the rulesets in force on it
869
+ * (`rules/branches/<branch>`, which anyone who can read the repository may
870
+ * see) and from classic protection as `branches/<branch>` reports it. Not
871
+ * `branches/<branch>/protection`: GitHub answers "Not Found" there to anyone
872
+ * who is not an admin, and it knows nothing of rulesets (UIG-6).
873
+ */
874
+ protectedBranch(pattern) {
875
+ const url = git("remote", "get-url", "origin");
876
+ const slug = url?.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1];
877
+ if (!slug) return UNKNOWN("no GitHub remote");
878
+ const branch = (git("symbolic-ref", "--short", "refs/remotes/origin/HEAD") ?? "origin/main").replace(/^origin\//, "");
879
+ const ask = (path) => JSON.parse(execFileSync2("gh", ["api", path], { stdio: ["ignore", "pipe", "pipe"], timeout: 2e4 }).toString());
880
+ let rules;
881
+ let classic;
882
+ try {
883
+ rules = ask(`repos/${slug}/rules/branches/${branch}`);
884
+ classic = ask(`repos/${slug}/branches/${branch}`).protection?.required_status_checks;
885
+ } catch (e) {
886
+ const s = said(e);
887
+ return UNKNOWN(`could not ask GitHub (${s.code === "ENOENT" ? "gh is not installed" : s.text.split("\n")[0] || s.message})`);
888
+ }
889
+ const names = [
890
+ ...rules.filter((r) => r.type === "required_status_checks").flatMap((r) => r.parameters?.required_status_checks ?? []).map((c) => c.context),
891
+ ...classic?.contexts ?? [],
892
+ ...(classic?.checks ?? []).map((c) => c.context)
893
+ ];
894
+ const hit = names.find((n) => pattern.test(n));
895
+ if (hit) return PASS2(`${slug} ${branch} requires "${hit}"`);
896
+ return FAIL(names.length ? `${slug} ${branch} requires ${names.map((n) => `"${n}"`).join(", ")}, none matching ${pattern}` : `${slug} ${branch} requires no check to merge`);
897
+ },
898
+ gh(args, label) {
899
+ try {
900
+ execFileSync2("gh", args, { stdio: ["ignore", "pipe", "pipe"], timeout: 2e4 });
901
+ return PASS2(label);
902
+ } catch (e) {
903
+ const s = said(e);
904
+ if (/could not resolve|not found|HTTP 404/i.test(s.text)) return FAIL(`not yet: ${label}`);
905
+ return UNKNOWN(`could not ask GitHub (${s.code === "ENOENT" ? "gh is not installed" : s.text.split("\n")[0] || s.message})`);
906
+ }
907
+ },
908
+ /** How many of `files` pass `test`: all is a pass, some is a part. */
909
+ share(files, test, label) {
910
+ if (!files.length) return FAIL(`no files to check for: ${label}`);
911
+ const ok = files.filter(test).length;
912
+ const detail = `${ok} of ${files.length}: ${label}`;
913
+ return ok === files.length ? PASS2(detail) : ok ? PART(detail) : FAIL(detail);
914
+ }
915
+ };
916
+ }
917
+ var STATUS = { done: "\u2705", started: "\u{1F6A7}", none: "\u2B1C", unknown: "\u2754" };
918
+ function statusOf(checks) {
919
+ const n = (r) => checks.filter((c) => c.result === r).length;
920
+ const pass = n("pass"), part = n("part"), fail = n("fail"), unknown = n("unknown");
921
+ if (!checks.length) return "unknown";
922
+ if (pass === checks.length) return "done";
923
+ if (pass + part > 0) return fail === 0 && unknown > 0 ? "unknown" : "started";
924
+ return fail > 0 ? "none" : "unknown";
925
+ }
926
+ async function runRepo(ROOT, app, h) {
927
+ const checksFile = join6(ROOT, app, "scripts", "gates-checks.mjs");
928
+ const define = (await import(pathToFileURL5(checksFile).href)).default;
929
+ const spec = define(h);
930
+ const git = (...a) => {
931
+ try {
932
+ return execFileSync2("git", a, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
933
+ } catch {
934
+ return null;
935
+ }
936
+ };
937
+ const rows = [];
938
+ for (const t of spec.tickets) {
939
+ const checks = [];
940
+ for (const c of t.checks ?? []) {
941
+ if (c.aggregate) continue;
942
+ let r;
943
+ try {
944
+ r = await c.run();
945
+ } catch (e) {
946
+ r = UNKNOWN(`the check threw: ${said(e).message}`);
947
+ }
948
+ checks.push({ repo: spec.repo, what: c.what, ...r });
949
+ }
950
+ rows.push({ ref: t.ref, title: t.title, owner: t.owner, checks });
951
+ }
952
+ return { spec, report: { schema: 1, repo: spec.repo, branch: git("rev-parse", "--abbrev-ref", "HEAD"), commit: git("rev-parse", "--short", "HEAD"), engine: ENGINE, rows } };
953
+ }
954
+ function runSibling(dir, app, shown) {
955
+ const copy = join6(dir, "scripts", "gates-status.mjs");
956
+ const installed = createRequire5(join6(dir, app, "package.json"));
957
+ let script = copy;
958
+ const args = ["--json"];
959
+ if (!existsSync5(copy)) {
960
+ try {
961
+ script = join6(installed.resolve("@estiva-app/ui/package.json"), "..", "dist", "gates", "cli.js");
962
+ args.unshift("status");
963
+ if (app !== ".") args.push("--app", app);
964
+ } catch {
965
+ return { error: `${shown} has neither its own gates-status.mjs nor @estiva-app/ui installed${app === "." ? "" : ` in ${app}`}` };
966
+ }
967
+ if (!existsSync5(script)) return { error: `${shown} has an @estiva-app/ui with no gates engine: install 0.21.0 or later` };
968
+ }
969
+ try {
970
+ const out = execFileSync2(process.execPath, [script, ...args], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], timeout: 3e5, maxBuffer: 16 * 1024 * 1024 });
971
+ return { report: JSON.parse(out.toString()) };
972
+ } catch (e) {
973
+ const err = e;
974
+ return { error: `its gates:status failed: ${String(err.stderr ?? err.message).split("\n").filter(Boolean).slice(-1)[0]}` };
975
+ }
976
+ }
977
+ function behindMain(dir) {
978
+ const git = (...a) => {
979
+ try {
980
+ return execFileSync2("git", a, { cwd: dir, stdio: ["ignore", "pipe", "ignore"], timeout: 3e4 }).toString().trim();
981
+ } catch {
982
+ return null;
983
+ }
984
+ };
985
+ if (git("rev-parse", "--is-inside-work-tree") !== "true") return "";
986
+ const fetched = git("fetch", "-q", "origin") !== null;
987
+ const main = (git("symbolic-ref", "--short", "refs/remotes/origin/HEAD") ?? "origin/main").trim();
988
+ const count = Number(git("rev-list", "--count", `HEAD..${main}`));
989
+ const stale = fetched ? "" : " (could not fetch: compared with the last fetch)";
990
+ if (!Number.isFinite(count) || count === 0) return stale ? ` \xB7${stale}` : "";
991
+ return ` \xB7 \u26A0\uFE0F ${count} commit${count === 1 ? "" : "s"} behind ${main}${stale}: pull, or this reads old code`;
992
+ }
993
+ function pad(s, n) {
994
+ const len = [...s].length;
995
+ return len >= n ? s : s + " ".repeat(n - len);
996
+ }
997
+ function printRows(rows, { showOwner, detail }) {
998
+ const lines = [];
999
+ const width = Math.max(...rows.map((r) => [...r.title ?? ""].length));
1000
+ for (const r of rows) {
1001
+ const pass = r.checks.filter((c) => c.result === "pass").length;
1002
+ const count = r.checks.length ? `${pass} of ${r.checks.length}` : "no checks here";
1003
+ lines.push(`${STATUS[r.status ?? "unknown"]} ${pad(r.ref, 7)} ${pad(r.title ?? "", width)} ${showOwner ? pad(r.ownerRepo ?? "", 10) : ""}${count}`);
1004
+ if (detail || r.status === "started" || r.status === "unknown") {
1005
+ for (const c of r.checks) {
1006
+ const mark = { pass: "\u2713", part: "\xBD", fail: "\u2717", unknown: "?" }[c.result];
1007
+ lines.push(` ${mark} ${showOwner ? `${pad(c.repo, 10)} ` : ""}${c.what} \u2014 ${c.detail}`);
1008
+ }
1009
+ }
1010
+ }
1011
+ return lines.join("\n");
1012
+ }
1013
+ function summary(rows) {
1014
+ const n = (s) => rows.filter((r) => r.status === s).length;
1015
+ 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`;
1016
+ }
1017
+ async function catalogueLines(appDir) {
1018
+ try {
1019
+ const { buildAppRegistry } = await import("../registry/index.js");
1020
+ const entries = buildAppRegistry({ root: appDir }).entries;
1021
+ const unused = entries.filter((e) => e.app?.class === "unused");
1022
+ const oneOffs = entries.filter((e) => e.app?.class === "one-off");
1023
+ const unlinked = oneOffs.filter((e) => !e.storyId && !e.docsId);
1024
+ return [
1025
+ `Unused parts: ${unused.length}${unused.length ? ` \xB7 Katerina rules on each: ${unused.map((e) => `${e.name} (${e.sourceFile})`).join(", ")}` : ""}`,
1026
+ `Storybook links: every reusable part has one (estiva-ui check holds it) \xB7 one-offs with none: ${unlinked.length} of ${oneOffs.length}, which need none`
1027
+ ];
1028
+ } catch (error) {
1029
+ return [`Catalogue: could not be built here: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`];
1030
+ }
1031
+ }
1032
+ async function runStatus({ root = process.cwd(), app = ".", json = false, detail = false, escapes = false } = {}) {
1033
+ const ROOT = resolve2(root);
1034
+ const h = helpers(ROOT);
1035
+ const shown = (dir) => (relative5(ROOT, dir) || dir).replace(/\\/g, "/");
1036
+ const { spec, report } = await runRepo(ROOT, app, h);
1037
+ if (json) return JSON.stringify(report);
1038
+ const out = [];
1039
+ out.push("UI Guardrails \xB7 gates:status", "Read from the code. Nothing here is a hand-ticked list.", "");
1040
+ out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}${behindMain(ROOT)}`);
1041
+ out.push("", ...escapeLines(listEscapes(join6(ROOT, app)), { all: escapes }));
1042
+ if (!spec.all) {
1043
+ out.push("", ...await catalogueLines(join6(ROOT, app)));
1044
+ const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }));
1045
+ const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: "", status: statusOf(r.checks) }));
1046
+ out.push("", `Tickets ${spec.repo} owns (${own.length}):`, printRows(own, { showOwner: false, detail }));
1047
+ out.push("", `Parts of tickets another repo owns, checked here (${parts.length}):`, printRows(parts, { showOwner: false, detail }));
1048
+ out.push("", "For every ticket across the repos, run npm run gates:status in estiva-ui.");
1049
+ return out.join("\n");
1050
+ }
1051
+ const reports = [report];
1052
+ const found = [];
1053
+ for (const s of spec.siblings ?? []) {
1054
+ const dir = resolve2(ROOT, process.env[s.env] ?? s.path);
1055
+ if (!existsSync5(join6(dir, "package.json"))) {
1056
+ found.push({ name: s.name, note: `not found at ${shown(dir)} (set ${s.env} to point at it)`, missing: true });
1057
+ continue;
1058
+ }
1059
+ const r = runSibling(dir, s.app ?? ".", shown(dir));
1060
+ if (!r.report) {
1061
+ found.push({ name: s.name, note: r.error ?? "no report", missing: true });
1062
+ continue;
1063
+ }
1064
+ 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" : "";
1065
+ found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}${behindMain(dir)}` });
1066
+ reports.push(r.report);
1067
+ }
1068
+ for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`);
1069
+ const problems = [];
1070
+ const known = new Set(spec.all.map((t) => t.ref));
1071
+ for (const rep of reports) {
1072
+ 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`);
1073
+ }
1074
+ const rows = spec.all.map((t) => {
1075
+ const pieces = reports.flatMap((rep) => rep.rows.filter((r) => r.ref === t.ref).map((r) => ({ rep, r })));
1076
+ const claims = pieces.filter((p) => p.r.owner).map((p) => p.rep.repo);
1077
+ const ownerMissing = found.some((f) => f.name === t.owner && f.missing);
1078
+ if (!ownerMissing && (claims.length !== 1 || claims[0] !== t.owner)) {
1079
+ problems.push(`${t.ref}: should be owned by ${t.owner}, is claimed by ${claims.join(" and ") || "no repo"}`);
1080
+ }
1081
+ for (const p of pieces.filter((p2) => !p2.r.owner && !(t.parts ?? []).includes(p2.rep.repo))) {
1082
+ problems.push(`${t.ref}: ${p.rep.repo} checks a part of it, but the ticket list does not name ${p.rep.repo}`);
1083
+ }
1084
+ const checks = pieces.flatMap((p) => p.r.checks);
1085
+ for (const f of found.filter((f2) => f2.missing)) {
1086
+ 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 });
1087
+ }
1088
+ return { ref: t.ref, title: t.title, ownerRepo: t.owner, checks, aggregate: t.aggregate };
1089
+ });
1090
+ for (const row of rows) row.status = statusOf(row.checks);
1091
+ for (const row of rows.filter((r) => r.aggregate)) {
1092
+ const others = rows.filter((r) => r !== row);
1093
+ const done = others.filter((r) => r.status === "done").length;
1094
+ 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`) });
1095
+ row.status = statusOf(row.checks);
1096
+ }
1097
+ out.push("", printRows(rows, { showOwner: true, detail }), "", summary(rows));
1098
+ const owners = Object.entries(rows.reduce((a, r) => ({ ...a, [r.ownerRepo ?? ""]: (a[r.ownerRepo ?? ""] ?? 0) + 1 }), {})).map(([k, v]) => `${k} ${v}`).join(", ");
1099
+ out.push(`${rows.length} tickets. Owned by ${owners}.`);
1100
+ const unchecked = found.filter((f) => f.missing && spec.all.some((t) => t.owner === f.name)).map((f) => f.name);
1101
+ if (problems.length) out.push(`\u26A0\uFE0F Ownership does not add up:
1102
+ ${problems.join("\n ")}`);
1103
+ else if (unchecked.length) out.push(`\u2754 Ownership not fully checked: ${unchecked.join(" and ")} could not be read.`);
1104
+ else out.push("\u2705 Each ticket is owned by exactly one repo, and every repo agrees.");
1105
+ if (!detail) out.push("Add -- --detail to see every check.");
1106
+ return out.join("\n");
1107
+ }
1108
+
1109
+ export {
1110
+ tokenStylePlugin,
1111
+ TOKEN_LINT_IGNORES,
1112
+ KNOWN_PLUGINS,
1113
+ tokenLint,
1114
+ tokenValues,
1115
+ tokenConfig,
1116
+ countCommitted,
1117
+ hookRuns,
1118
+ skippedFolders,
1119
+ skipsListed,
1120
+ runCi,
1121
+ writeGateCount,
1122
+ runHook,
1123
+ nameEscapes,
1124
+ REVIEW_AFTER_DAYS,
1125
+ listEscapes,
1126
+ escapeLines,
1127
+ ENGINE,
1128
+ helpers,
1129
+ runStatus
1130
+ };
1131
+ //# sourceMappingURL=chunk-SYVKGOWM.js.map