cursedbelt 5.30.0 → 5.32.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 (40) hide show
  1. package/dist/react/components/ConfirmDialog.d.ts +4 -1
  2. package/dist/react/components/ConfirmDialog.d.ts.map +1 -1
  3. package/dist/react/components/ConfirmDialog.js +2 -2
  4. package/dist/react/components/ConfirmDialog.js.map +1 -1
  5. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  6. package/dist/react/components/DataTable/DataTable.js +5 -1
  7. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  8. package/dist/react/components/PromptDialog.d.ts +4 -1
  9. package/dist/react/components/PromptDialog.d.ts.map +1 -1
  10. package/dist/react/components/PromptDialog.js +2 -2
  11. package/dist/react/components/PromptDialog.js.map +1 -1
  12. package/dist/react/file-tree/FileTree.d.ts +26 -1
  13. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  14. package/dist/react/file-tree/FileTree.js +182 -40
  15. package/dist/react/file-tree/FileTree.js.map +1 -1
  16. package/dist/scripts/guardrailsEnforce.d.ts +11 -0
  17. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  18. package/dist/testing/index.d.ts +3 -0
  19. package/dist/testing/index.d.ts.map +1 -0
  20. package/dist/testing/index.js +4 -0
  21. package/dist/testing/index.js.map +1 -0
  22. package/dist/testing/objectLeak.d.ts +23 -0
  23. package/dist/testing/objectLeak.d.ts.map +1 -0
  24. package/dist/testing/objectLeak.js +37 -0
  25. package/dist/testing/objectLeak.js.map +1 -0
  26. package/package.json +5 -5
  27. package/scripts/guardrails-baseline.json +4 -1
  28. package/scripts/guardrailsEnforce.spec.ts +163 -6
  29. package/scripts/guardrailsEnforce.ts +365 -40
  30. package/scripts/paths.ts +1 -1
  31. package/src/react/components/ConfirmDialog.tsx +5 -0
  32. package/src/react/components/DataTable/DataTable.spec.tsx +16 -0
  33. package/src/react/components/DataTable/DataTable.tsx +5 -1
  34. package/src/react/components/PromptDialog.spec.tsx +29 -1
  35. package/src/react/components/PromptDialog.tsx +5 -0
  36. package/src/react/file-tree/FileTree.spec.tsx +205 -0
  37. package/src/react/file-tree/FileTree.tsx +220 -38
  38. package/src/testing/index.ts +3 -0
  39. package/src/testing/objectLeak.spec.ts +21 -0
  40. package/src/testing/objectLeak.ts +39 -0
@@ -71,7 +71,7 @@
71
71
  // className="h-8">`, a Progress bar's `h-1.5` track) in some other file never
72
72
  // false-positives.
73
73
  //
74
- // no-raw-data-table — all-apps-tables.md (owner, 2026-08-14): no hand-rolled `<table>` —
74
+ // no-raw-data-table — DESIGN.md §3 "Visual Formatting & Data Presentation" (owner, 2026-08-14): no hand-rolled `<table>` —
75
75
  // use `cursedbelt/react/data-table`'s `DataTable`, which carries sorting, a global
76
76
  // filter, column visibility, pagination and the phone card tier. The rule is on the
77
77
  // ELEMENT rather than on "did it remember to sort", because a scanner cannot decide the
@@ -80,7 +80,7 @@
80
80
  // exempt by construction; a genuine exception (a diff, an editable grid) takes an
81
81
  // opt-out with a reason, which is checked over the whole contiguous comment block above.
82
82
  //
83
- // no-tailwind-palette — DESIGN.md §3 / UX_STANDARDS.md §6.6: no raw Tailwind palette
83
+ // no-tailwind-palette — DESIGN.md §3 (Visual Formatting & Data Presentation): no raw Tailwind palette
84
84
  // class (`bg-slate-800`, `hover:text-red-500`, `dark:border-zinc-700/40`). Those are
85
85
  // Tailwind's own colours, not the theme's tokens: they do not move when the theme
86
86
  // moves, so an app painted in them has silently opted out of every future design fix.
@@ -134,13 +134,20 @@
134
134
  // which is a request rather than an accident) instead of a literal. The WHY is
135
135
  // `src/react/lib/hoverIntent.ts`.
136
136
  //
137
- // no-local-reimplementation — all-apps-ux.md (owner, 2026-08-13): an app must not
137
+ // no-local-reimplementation — DESIGN.md "Core Mandate" (owner, 2026-08-13): an app must not
138
138
  // declare its own component under a name cursedbelt already exports. A local
139
139
  // `EmptyState`/`Modal`/`Badge` compiles, typechecks and looks approximately right,
140
140
  // so nothing reports it — and it is precisely why a fix that landed in the design
141
141
  // system never reached the screen. The name list is passed in with
142
- // `--reimplements <Name,…>` from `fleet/standards/CURSEDBELT_INVENTORY.md`; with no
143
- // list the rule is inert rather than guessing.
142
+ // `--reimplements-from-belt` (this package's own react exports) or `--reimplements
143
+ // <Name,…>`; with no list the rule is inert rather than guessing. Near spellings count
144
+ // too (task 2381): `CheckBox`, `StatusBadge`, `Stat`, `ToastStack`.
145
+ //
146
+ // no-inline-style — DESIGN.md §3/§4 (task 2381): `style={{…}}` may carry only a CSS
147
+ // custom property or COMPUTED geometry; a literal size, colour or font is a violation.
148
+ //
149
+ // opt-out-count / opt-out-needs-cause — task 2381: every opt-out is counted, and each
150
+ // leads with a cause from a closed vocabulary. See their block.
144
151
  //
145
152
  // no-raw-password — owner, 2026-09-18: every password field has an eye. Flags
146
153
  // `type="password"` on ANY tag or props object (the `<Input type="password">` form that
@@ -170,10 +177,15 @@
170
177
  // // guardrails-ignore no-hardcoded-colors: SVG data-URI icon, not a themeable surface
171
178
  // const icon = "data:image/svg+xml,...#ff0000...";
172
179
  //
173
- // A recognized presentational ARIA role/attribute on the tag (role="tab" | "switch" |
174
- // "radio" | "menuitemradio" | "presentation", or aria-selected) also counts as an
175
- // opt-out for `no-raw-action-button` — these are the standard markers for a
176
- // presentational toggle/tab widget.
180
+ // A presentational ARIA role/attribute on the tag (role="tab" | "switch" | "radio" |
181
+ // "menuitemradio" | "presentation", or aria-selected) exempts a `<button>` from
182
+ // `no-raw-action-button` ONLY inside a belt `Tabs`/`TabsList`/`SegmentedControl`/
183
+ // `RadioGroup`/`ToggleGroup` (task 2381 — a bare marker was the loophole; see the rule).
184
+ //
185
+ // 🔴 Every opt-out is COUNTED (`opt-out-count`) and must lead with its cause
186
+ // (`opt-out-needs-cause`): `guardrails-ignore <rule>: <cause> — <six+ words of why>`, the
187
+ // cause one of `canvas`, `third-party`, `native-media`, `perf`, `a11y-needs`, or a task
188
+ // number. See those two rules' block.
177
189
  //
178
190
  // File-scoped opt-out — `guardrails-file-allow <rule>[,<rule>...]: <category> — <reason>`:
179
191
  // A handful of files legitimately violate several of these rules PERVASIVELY, not at a
@@ -501,13 +513,38 @@ const IGNORE_COMMENT_RE = /guardrails-ignore\s+no-raw-action-button\s*:/;
501
513
  const PRESENTATIONAL_MARKER_RE =
502
514
  /role\s*=\s*["'](tab|switch|radio|menuitemradio|presentation)["']|aria-selected\s*=/;
503
515
 
516
+ // Task 2381: a role marker used to exempt ANY raw button, so the cheapest way past this rule
517
+ // was `role="switch"` — `apps/family/src/App.tsx:1786-1800` (`EditModeToggle`) says in its
518
+ // own comment the marker is there to satisfy it, beside the belt's `Switch`. A marker now
519
+ // exempts only a button INSIDE a belt group that owns that role (`Tabs`, `SegmentedControl`,
520
+ // `RadioGroup`, `ToggleGroup`) — the one place a raw button with it is the group's own part.
521
+ // Anywhere else it is `Switch` / `Tabs` / `SegmentedControl` / `RadioGroup`, or an opt-out
522
+ // with its cause.
523
+ const BELT_OWN_SRC = `${join(import.meta.dir, "..", "src")}/`;
524
+ const isBeltOwnSource = (filePath: string) =>
525
+ resolve(filePath).startsWith(BELT_OWN_SRC) || /(^|\/)(?:packages|libs)\/cursedbelt\/src\//.test(filePath.replace(/\\/g, "/"));
526
+ const BELT_CHOICE_GROUPS = ["Tabs", "TabsList", "SegmentedControl", "RadioGroup", "ToggleGroup"];
527
+
528
+ /** Is `index` inside an open (not yet closed) belt choice-group element in this file? */
529
+ function insideBeltChoiceGroup(scannable: string, index: number): boolean {
530
+ const before = scannable.slice(0, index);
531
+ return BELT_CHOICE_GROUPS.some((name) => {
532
+ const open = Math.max(...[...before.matchAll(new RegExp(`<${name}\\b`, "g"))].map((m) => m.index ?? -1), -1);
533
+ if (open === -1) return false;
534
+ const tag = readOpeningTag(scannable, open);
535
+ if (/\/\s*>$/.test(tag.code.trimEnd())) return false;
536
+ return !before.slice(open).includes(`</${name}>`);
537
+ });
538
+ }
539
+
504
540
  const noRawActionButton: Rule = {
505
541
  name: "no-raw-action-button",
506
542
  description:
507
- 'STANDARDS.md §4a: no hand-rolled <button> for actions — use the cursedbelt Button family.',
543
+ 'DESIGN.md §2 (Component Architecture): no hand-rolled <button> — use the cursedbelt Button family; a toggle is `Switch`, a tab `Tabs`, a choice `SegmentedControl`/`RadioGroup`. A role marker exempts a button only inside one of those groups.',
508
544
  check(filePath, source) {
509
545
  const violations: Violation[] = [];
510
546
  const scannable = blankNonMarkupText(source);
547
+ let commentless: string | undefined;
511
548
  RAW_BUTTON_RE.lastIndex = 0;
512
549
  for (const match of scannable.matchAll(RAW_BUTTON_RE)) {
513
550
  // Re-read the tag from the ORIGINAL source so opt-out comments (which were
@@ -516,7 +553,13 @@ const noRawActionButton: Rule = {
516
553
  // check is looking for a comment, the marker check for a real attribute, so
517
554
  // prose quoting `role="radio"` opts nothing out.
518
555
  const tag = readOpeningTag(source, match.index);
519
- if (IGNORE_COMMENT_RE.test(tag.text) || PRESENTATIONAL_MARKER_RE.test(tag.code)) continue;
556
+ if (IGNORE_COMMENT_RE.test(tag.text)) continue;
557
+ if (PRESENTATIONAL_MARKER_RE.test(tag.code)) {
558
+ // cursedbelt's own source IS those groups (`RatingField`'s stars are its radios).
559
+ if (isBeltOwnSource(filePath)) continue;
560
+ commentless ??= blankComments(source);
561
+ if (insideBeltChoiceGroup(commentless, match.index)) continue;
562
+ }
520
563
  const line = lineOf(source, match.index);
521
564
  violations.push({
522
565
  file: filePath,
@@ -832,7 +875,7 @@ const noRawZindex: Rule = {
832
875
  };
833
876
 
834
877
  // ── no-raw-data-table ───────────────────────────────────────────────────────
835
- // The owner, 2026-08-14 (___Unsent-Todos/all-apps-tables.md):
878
+ // The owner, 2026-08-14 (a note in his own queue, not in any tree):
836
879
  //
837
880
  // *"find tables of data that are not using standardized cb filters and sorting and make
838
881
  // them use it. I want filters and sorting on all tables in all apps and a single
@@ -870,7 +913,7 @@ const RAW_TABLE_EXEMPT_FRAGMENTS = [
870
913
  const noRawDataTable: Rule = {
871
914
  name: "no-raw-data-table",
872
915
  description:
873
- "all-apps-tables.md: no hand-rolled <table> — use cursedbelt's DataTable (cursedbelt/react/data-table), which carries sorting, filtering, column visibility and the phone card tier. A hand-rolled table is how a grid ships with no sort and no filter.",
916
+ "DESIGN.md §3 (Visual Formatting & Data Presentation): no hand-rolled <table> — use cursedbelt's DataTable (cursedbelt/react/data-table), which carries sorting, filtering, column visibility and the phone card tier. A hand-rolled table is how a grid ships with no sort and no filter.",
874
917
  check(filePath, source) {
875
918
  const normalized = filePath.replace(/\\/g, "/");
876
919
  if (RAW_TABLE_EXEMPT_FRAGMENTS.some((fragment) => normalized.includes(fragment))) return [];
@@ -960,7 +1003,7 @@ const EDITING_MODAL_IGNORE_RE = /guardrails-ignore\s+no-unguarded-editing-modal\
960
1003
  * deliberately-bad ones written to prove the guard fires.
961
1004
  */
962
1005
  const isDialogLibrarySource = (path: string): boolean =>
963
- /(^|\/)packages\/cursedbelt\/src\/react\//.test(path) ||
1006
+ /(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\//.test(path) ||
964
1007
  /(^|\/)src\/react\/(components|layoutEngine|rich-text|portal)\//.test(path);
965
1008
  const isTestSource = (path: string): boolean => /\.(spec|test)\.[jt]sx?$/.test(path);
966
1009
 
@@ -1077,7 +1120,7 @@ const noUnguardedEditingModal: Rule = {
1077
1120
  },
1078
1121
  };
1079
1122
 
1080
- // ── no-tailwind-palette (DESIGN.md §3 / UX_STANDARDS.md §6.6) ───────────────
1123
+ // ── no-tailwind-palette (DESIGN.md §3 (Visual Formatting & Data Presentation)) ───────────────
1081
1124
  // `no-hardcoded-colors` already bans a hex or a literal `rgb()`. It cannot see the
1082
1125
  // OTHER way a colour gets hardcoded, which in a Tailwind codebase is the common one:
1083
1126
  // `bg-slate-800`, `text-red-500`, `border-zinc-700`. Those are not tokens. They are
@@ -1085,7 +1128,7 @@ const noUnguardedEditingModal: Rule = {
1085
1128
  // "one fix per component type" fails — an app painted in `slate-800` has opted out of
1086
1129
  // every future change to `--surface-2`, silently, and nothing anywhere is wrong.
1087
1130
  //
1088
- // UX_STANDARDS.md §6.6 states the law ("One theme system: cursedbelt's tokens. An app
1131
+ // The retired UX standard (§6.6) stated the law ("One theme system: cursedbelt's tokens. An app
1089
1132
  // that hardcodes a colour has opted out of every future fix") and DESIGN.md §3 states
1090
1133
  // its visible consequence (badges must clear 4.5:1 in DARK mode — a palette step
1091
1134
  // chosen against a light mock cannot). This rule is that law, checked.
@@ -1120,7 +1163,7 @@ const TAILWIND_PALETTE_RE = new RegExp(
1120
1163
 
1121
1164
  const noTailwindPalette: Rule = makeValueRule(
1122
1165
  "no-tailwind-palette",
1123
- "DESIGN.md §3 / UX_STANDARDS.md §6.6: no raw Tailwind palette class (bg-slate-800, text-red-500, …) — those are not tokens and do not move when the theme moves. Use the theme's semantic classes or bg-[var(--token)].",
1166
+ "DESIGN.md §3 (Visual Formatting & Data Presentation): no raw Tailwind palette class (bg-slate-800, text-red-500, …) — those are not tokens and do not move when the theme moves. Use the theme's semantic classes or bg-[var(--token)].",
1124
1167
  TAILWIND_PALETTE_RE,
1125
1168
  );
1126
1169
 
@@ -1226,7 +1269,7 @@ const themeColoursResolve: Rule = {
1226
1269
  // waits ~500ms before appearing, it cannot be styled or themed, and a screen reader
1227
1270
  // may read it, ignore it, or double it up with the accessible name depending on the
1228
1271
  // element. DESIGN.md §4 asks for explanatory text "behind interactive hover info icon
1229
- // tooltips (i)"; UX_STANDARDS.md §5.1 states the phone half ("a control that only
1272
+ // tooltips (i)"; DESIGN.md §5 (Mobile Target Safeguards) carries the phone half ("a control that only
1230
1273
  // exists on hover does not exist on a phone").
1231
1274
  //
1232
1275
  // cursedbelt has both halves already: `Tooltip` (hover + focus + touch long-press,
@@ -1276,7 +1319,7 @@ const noRawTitleAttr: Rule = {
1276
1319
  };
1277
1320
 
1278
1321
  // ── no-local-reimplementation (the reason the Optimized-Tasks queue exists) ─
1279
- // The owner, 2026-08-13 (___Todo-Ready/all-apps-ux.md):
1322
+ // The owner, 2026-08-13 (a note in his own queue, not in any tree):
1280
1323
  //
1281
1324
  // *"apply the quality SaaS product enhancement recommendations everywhere in all the
1282
1325
  // apps because these patterns were everywhere and we need ONE FIX FOR EACH COMPONENT
@@ -1294,38 +1337,77 @@ const noRawTitleAttr: Rule = {
1294
1337
  // (rename it). Both are worth a line of a person's attention, and neither is
1295
1338
  // detectable any other way — an import cursedbelt does not have is not an error.
1296
1339
  //
1297
- // The inventory that feeds this list is `fleet/standards/CURSEDBELT_INVENTORY.md`;
1298
- // `--reimplements <Name,Name>` passes it in, so the list lives with the inventory and
1299
- // not buried in this file. With no list passed the rule is inert rather than guessing.
1340
+ // The list is this package's own react exports (`--reimplements-from-belt`), or a caller's
1341
+ // own `--reimplements <Name,Name>`. With no list passed the rule is inert rather than guessing.
1300
1342
  const REIMPL_IGNORE_RE = /guardrails-ignore\s+no-local-reimplementation\s*:/;
1301
1343
  /** Set by `--reimplements` (CLI) or `setReimplementedNames()` (library use). */
1302
1344
  let REIMPLEMENTED_NAMES: string[] = [];
1345
+ /** Normalised key → the belt name it stands for (see `nearNameKeys`). */
1346
+ let NEAR_NAMES = new Map<string, string>();
1303
1347
  export function setReimplementedNames(names: readonly string[]): void {
1304
1348
  REIMPLEMENTED_NAMES = [...names];
1349
+ NEAR_NAMES = new Map();
1350
+ for (const name of names) for (const key of nearNameKeys(name, true)) if (!NEAR_NAMES.has(key)) NEAR_NAMES.set(key, name);
1351
+ }
1352
+
1353
+ // Task 2381: exact-match missed the copies that matter, because a copy is rarely spelled
1354
+ // the same — desk's `CheckBox` (belt `Checkbox`), station's `StatusBadge` (`StatusPill`),
1355
+ // roms' `Stat` (`StatTile`), desk's `ToastStack` (`Toaster`). The key is lowercase, drops a
1356
+ // possessive prefix (`Local|App|My|Custom`) and folds the chip-shaped suffixes
1357
+ // (`Badge|Pill|Tile|Chip|Tag`) into one; a belt name carrying one of those suffixes also
1358
+ // answers to its bare stem, and `STEM_ALIASES` holds the few a suffix rule cannot reach.
1359
+ // It stops there on purpose: `TreeToolbar` → `ListToolbar` is a judgement, and a rule that
1360
+ // guesses is one people route around.
1361
+ const NEAR_NAME_PREFIX_RE = /^(?:Local|App|My|Custom)(?=[A-Z])/;
1362
+ const NEAR_NAME_SUFFIX_RE = /(?<=[a-z0-9])(?:Badge|Pill|Tile|Chip|Tag)$/;
1363
+ const STEM_ALIASES: Record<string, readonly string[]> = { Toaster: ["ToastStack", "Toasts", "ToastHost"] };
1364
+ const MIN_NEAR_KEY = 3;
1365
+
1366
+ function nearNameKeys(name: string, isBelt = false): string[] {
1367
+ const unprefixed = name.replace(NEAR_NAME_PREFIX_RE, "");
1368
+ const suffixed = NEAR_NAME_SUFFIX_RE.test(unprefixed);
1369
+ const stem = unprefixed.replace(NEAR_NAME_SUFFIX_RE, "");
1370
+ const keys = [`${stem.toLowerCase()}${suffixed ? "~chip" : ""}`];
1371
+ if (isBelt && suffixed) keys.push(stem.toLowerCase());
1372
+ if (isBelt) for (const alias of STEM_ALIASES[name] ?? []) keys.push(alias.toLowerCase());
1373
+ return keys.filter((key) => key.replace("~chip", "").length >= MIN_NEAR_KEY);
1374
+ }
1375
+
1376
+ /** The belt name a locally declared `name` duplicates, exactly or nearly — or null. */
1377
+ function reimplementedBeltName(name: string): string | null {
1378
+ if (REIMPLEMENTED_NAMES.includes(name)) return name;
1379
+ for (const key of nearNameKeys(name)) {
1380
+ const belt = NEAR_NAMES.get(key);
1381
+ if (belt) return belt;
1382
+ }
1383
+ return null;
1305
1384
  }
1306
1385
 
1386
+ const LOCAL_COMPONENT_DECL_RE = /(?:export\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9]*)\b|(?:export\s+)?const\s+([A-Z][A-Za-z0-9]*)\s*[:=]/g;
1387
+
1307
1388
  const noLocalReimplementation: Rule = {
1308
1389
  name: "no-local-reimplementation",
1309
1390
  description:
1310
- "all-apps-ux.md: an app must not declare its own component under a name cursedbelt already exports — a local copy is why a fix that landed in the design system did not reach the screen. Import the shared one, or rename the local thing so it stops claiming to be it.",
1391
+ "DESIGN.md Core Mandate: an app must not declare its own component under a name cursedbelt already exports, or a near spelling of one (`CheckBox`/`Checkbox`, `StatusBadge`/`StatusPill`, `Stat`/`StatTile`) — a local copy is why a fix that landed in the design system did not reach the screen. Import the shared one, or rename the local thing so it stops claiming to be it.",
1311
1392
  check(filePath, source) {
1312
1393
  if (REIMPLEMENTED_NAMES.length === 0) return [];
1313
1394
  const normalized = filePath.replace(/\\/g, "/");
1314
1395
  if (/\.(?:spec|test)\.[tj]sx?$/.test(normalized)) return [];
1315
1396
  // cursedbelt IS the implementation of these names.
1316
- if (/(^|\/)packages\/cursedbelt\/src\//.test(normalized)) return [];
1397
+ if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\//.test(normalized)) return [];
1317
1398
  const violations: Violation[] = [];
1318
1399
  const scannable = blankComments(source);
1319
1400
  const lines = source.split("\n");
1320
- for (const name of REIMPLEMENTED_NAMES) {
1321
- const re = new RegExp(`(?:export\\s+)?(?:function|class)\\s+${name}\\b|(?:export\\s+)?const\\s+${name}\\s*[:=]`, "g");
1322
- for (const match of scannable.matchAll(re)) {
1323
- const index = match.index ?? 0;
1324
- const line = lineOf(source, index);
1325
- const windowStart = Math.max(0, line - 3);
1326
- if (REIMPL_IGNORE_RE.test(lines.slice(windowStart, line).join("\n"))) continue;
1327
- violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
1328
- }
1401
+ LOCAL_COMPONENT_DECL_RE.lastIndex = 0;
1402
+ for (const match of scannable.matchAll(LOCAL_COMPONENT_DECL_RE)) {
1403
+ const name = match[1] ?? match[2] ?? "";
1404
+ const belt = reimplementedBeltName(name);
1405
+ if (!belt) continue;
1406
+ const line = lineOf(source, match.index ?? 0);
1407
+ const windowStart = Math.max(0, line - 3);
1408
+ if (REIMPL_IGNORE_RE.test(lines.slice(windowStart, line).join("\n"))) continue;
1409
+ const snippet = lines[line - 1]?.trim() ?? "";
1410
+ violations.push({ file: filePath, line, snippet: belt === name ? snippet : `${snippet} ⟵ ≈ cursedbelt's ${belt}` });
1329
1411
  }
1330
1412
  return violations;
1331
1413
  },
@@ -1571,7 +1653,7 @@ const choiceNeedsEmptyState: Rule = {
1571
1653
  description:
1572
1654
  "music-app-clicking-favorite-refreshes.md: a choice control whose options come from DATA must pass `emptyLabel` — an empty popup is a dead end that reads as a broken control. Say what belongs in the list, and use ChoiceMenu where making one is possible (it puts 'New <thing>…' first). A list built from a constant is exempt automatically.",
1573
1655
  check(filePath, source) {
1574
- if (/(^|\/)packages\/cursedbelt\/src\/react\/components\//.test(filePath.replace(/\\/g, "/"))) {
1656
+ if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\//.test(filePath.replace(/\\/g, "/"))) {
1575
1657
  return [];
1576
1658
  }
1577
1659
  const lines = source.split("\n");
@@ -1588,7 +1670,7 @@ const noSelectAsActionMenu: Rule = {
1588
1670
  description:
1589
1671
  "music-app-clicking-favorite-refreshes.md: a select pinned to value=\"\" is an ACTION MENU wearing a form control — and a native <select>'s popup is drawn by the browser outside the page, where no stylesheet reaches it ('the dropdowns open with a gray bland styling'). Use ChoiceMenu: styled, a create row first, and a real empty state.",
1590
1672
  check(filePath, source) {
1591
- if (/(^|\/)packages\/cursedbelt\/src\/react\/components\//.test(filePath.replace(/\\/g, "/"))) {
1673
+ if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\//.test(filePath.replace(/\\/g, "/"))) {
1592
1674
  return [];
1593
1675
  }
1594
1676
  const lines = source.split("\n");
@@ -2206,6 +2288,232 @@ const noHandRolledSurface: Rule = {
2206
2288
  },
2207
2289
  };
2208
2290
 
2291
+ // ── no-inline-style (task 2381) ─────────────────────────────────────────────────
2292
+ // DESIGN.md §3 ("Visual Formatting & Data Presentation") and §4 ("Typography & Information
2293
+ // Density"): the look comes from the theme. Every other rule here reads a className or a
2294
+ // colour literal — and `style={{ fontSize: "0.75rem", color: … }}` walks past all of them,
2295
+ // because it is neither. The review of 2026-09-30 counted roms 335, family 61, desk 51,
2296
+ // fractals 50; `apps/roms/src/views/StatsView.tsx:97-102` hand-rolled a whole stat tile that
2297
+ // way, beside the belt's `StatTile`.
2298
+ //
2299
+ // What an inline style is FOR, and all this rule lets through:
2300
+ // · a CSS custom property (`"--progress": pct`) — the token hand-off to a stylesheet;
2301
+ // · COMPUTED geometry — `width|height|min/max-*|left|top|right|bottom|inset|transform|
2302
+ // translate` whose value is an expression (`${x}px`, `rect.top`, `size * 2`), which a
2303
+ // class cannot carry;
2304
+ // · a spread (`{...rest}`) — the caller's own, and not this file's to judge.
2305
+ // Everything else — a literal size, a colour, a font, a literal geometry value a class
2306
+ // would carry — is a violation. One per `style={{`, naming the offending keys.
2307
+ const INLINE_STYLE_RE = /\bstyle\s*=\s*\{\s*\{/g;
2308
+ const INLINE_STYLE_IGNORE_RE = /guardrails-ignore\s+no-inline-style\s*:/;
2309
+ const COMPUTED_GEOMETRY_KEYS = new Set([
2310
+ "width",
2311
+ "height",
2312
+ "minWidth",
2313
+ "maxWidth",
2314
+ "minHeight",
2315
+ "maxHeight",
2316
+ "left",
2317
+ "top",
2318
+ "right",
2319
+ "bottom",
2320
+ "inset",
2321
+ "transform",
2322
+ "translate",
2323
+ ]);
2324
+ /** A value a stylesheet could have carried: a number, a quoted string, a template with no `${`. */
2325
+ const LITERAL_STYLE_VALUE_RE = /^(?:-?\d+(?:\.\d+)?|"[^"]*"|'[^']*'|`[^`$]*`)$/;
2326
+
2327
+ /** Split an object literal's body on its top-level commas (strings, templates and brackets respected). */
2328
+ function topLevelEntries(body: string): string[] {
2329
+ const out: string[] = [];
2330
+ let depth = 0;
2331
+ let start = 0;
2332
+ for (let i = 0; i < body.length; i++) {
2333
+ const ch = body[i];
2334
+ if (ch === '"' || ch === "'" || ch === "`") {
2335
+ i = skipQuoted(body, i) - 1;
2336
+ continue;
2337
+ }
2338
+ if (ch === "{" || ch === "(" || ch === "[") depth++;
2339
+ else if (ch === "}" || ch === ")" || ch === "]") depth--;
2340
+ else if (ch === "," && depth === 0) {
2341
+ out.push(body.slice(start, i));
2342
+ start = i + 1;
2343
+ }
2344
+ }
2345
+ out.push(body.slice(start));
2346
+ return out.map((entry) => entry.trim()).filter(Boolean);
2347
+ }
2348
+
2349
+ /** The keys of one `style={{ … }}` object that are not a custom property or computed geometry. */
2350
+ export function disallowedInlineStyleKeys(objectBody: string): string[] {
2351
+ const bad: string[] = [];
2352
+ for (const entry of topLevelEntries(objectBody)) {
2353
+ if (entry.startsWith("...")) continue;
2354
+ const colon = entry.search(/:/);
2355
+ const rawKey = (colon === -1 ? entry : entry.slice(0, colon)).trim();
2356
+ const key = rawKey.replace(/^\[?\s*["'`]?|["'`]?\s*\]?$/g, "");
2357
+ if (key.startsWith("--")) continue;
2358
+ const value = colon === -1 ? key : entry.slice(colon + 1).trim();
2359
+ if (COMPUTED_GEOMETRY_KEYS.has(key) && !LITERAL_STYLE_VALUE_RE.test(value)) continue;
2360
+ bad.push(key);
2361
+ }
2362
+ return bad;
2363
+ }
2364
+
2365
+ const noInlineStyle: Rule = {
2366
+ name: "no-inline-style",
2367
+ description:
2368
+ "DESIGN.md §3/§4: no inline `style={{…}}` look — a literal size, colour or font belongs to a class or a theme token. An inline style may carry only a CSS custom property (`\"--x\": v`) or COMPUTED geometry (width/height/left/top/transform… from an expression).",
2369
+ check(filePath, source) {
2370
+ const normalized = filePath.replace(/\\/g, "/");
2371
+ if (isTestSource(normalized) || extname(normalized) === ".css") return [];
2372
+ const scannable = blankComments(source);
2373
+ const lines = source.split("\n");
2374
+ const out: Violation[] = [];
2375
+ INLINE_STYLE_RE.lastIndex = 0;
2376
+ for (const match of scannable.matchAll(INLINE_STYLE_RE)) {
2377
+ const inner = (match.index ?? 0) + match[0].length - 1;
2378
+ const bad = disallowedInlineStyleKeys(balancedBraces(scannable, inner));
2379
+ if (bad.length === 0) continue;
2380
+ const line = lineOf(source, match.index ?? 0);
2381
+ if (INLINE_STYLE_IGNORE_RE.test(`${lines[line - 1] ?? ""}\n${blockAbove(lines, line)}`)) continue;
2382
+ out.push({ file: filePath, line, snippet: `${lines[line - 1]?.trim().slice(0, 120) ?? ""} ⟵ ${bad.join(", ")}` });
2383
+ }
2384
+ return out;
2385
+ },
2386
+ };
2387
+
2388
+ // ── Opt-outs are counted, and each one names its cause (task 2381) ──────────────
2389
+ // A `guardrails-ignore <rule>: <reason>` used to need only the colon. The review of
2390
+ // 2026-09-30 counted 421 of them across eleven apps, plus four file-wide allowances — one
2391
+ // over all 1,631 lines of `apps/fractals/src/engine/FractalStudio.tsx` — and read them:
2392
+ // "fullscreen overlay chrome — UI state only", "control over a video frame". A zero
2393
+ // baseline beside that pile does not mean zero raw buttons; it means the cheapest move
2394
+ // facing a rule was a comment. Two rules close that:
2395
+ //
2396
+ // opt-out-count — every opt-out (per-line AND file-wide) is one count, so the
2397
+ // consumer's two-sided baseline row can only fall, and a raise
2398
+ // needs `allow-raise:` (tools/check-baseline-raises.ts).
2399
+ // opt-out-needs-cause — the reason must START with a cause from a closed vocabulary —
2400
+ // `canvas`, `third-party`, `native-media`, `perf`, `a11y-needs` —
2401
+ // or a task number (`task 2381`, `#2381`), then say why in at
2402
+ // least six words. A directive naming no known rule suppresses
2403
+ // nothing and is counted here too. A file-wide allowance's
2404
+ // category is its cause; its reason still needs six words.
2405
+ //
2406
+ // // guardrails-ignore no-raw-inputs: canvas — the hidden input feeds the WebGL caret
2407
+ //
2408
+ // Neither rule has an opt-out of its own, and neither can be file-allowed: an exemption
2409
+ // from counting exemptions is the loophole again.
2410
+ const OPT_OUT_CAUSES = ["canvas", "third-party", "native-media", "perf", "a11y-needs"] as const;
2411
+ const OPT_OUT_MIN_WORDS = 6;
2412
+ const META_RULES: ReadonlySet<string> = new Set(["opt-out-count", "opt-out-needs-cause"]);
2413
+ const OPT_OUT_DIRECTIVE_RE = /guardrails-ignore\s+([a-z0-9-]+)\s*:/g;
2414
+ const OPT_OUT_CAUSE_RE = new RegExp(`^(?:(?:${OPT_OUT_CAUSES.join("|")})\\b|task[\\s-]*#?\\d+\\b|#\\d+\\b)`, "i");
2415
+
2416
+ interface OptOut {
2417
+ rule: string;
2418
+ reason: string;
2419
+ line: number;
2420
+ }
2421
+
2422
+ /** The reason text after a directive's colon, through the rest of its comment. */
2423
+ function optOutReason(source: string, lineStart: number, colonEnd: number): string {
2424
+ const lines = source.slice(colonEnd).split("\n");
2425
+ const first = lines[0] ?? "";
2426
+ const closeAt = first.indexOf("*/");
2427
+ if (closeAt !== -1) return first.slice(0, closeAt);
2428
+ const prefix = source.slice(lineStart, colonEnd);
2429
+ const parts = [first];
2430
+ if (prefix.includes("//")) {
2431
+ for (const next of lines.slice(1, 6)) {
2432
+ const trimmed = next.trim();
2433
+ if (!trimmed.startsWith("//") || /guardrails-(?:ignore|file-allow)/.test(trimmed)) break;
2434
+ parts.push(trimmed.replace(/^\/\/+/, ""));
2435
+ }
2436
+ } else {
2437
+ for (const next of lines.slice(1, 6)) {
2438
+ const close = next.indexOf("*/");
2439
+ parts.push((close === -1 ? next : next.slice(0, close)).trim().replace(/^\*+/, ""));
2440
+ if (close !== -1) break;
2441
+ }
2442
+ }
2443
+ return parts.join(" ");
2444
+ }
2445
+
2446
+ /** Every `guardrails-ignore` directive written in a COMMENT (prose in a string is not one). */
2447
+ export function findOptOuts(source: string): OptOut[] {
2448
+ const scannable = blankComments(source);
2449
+ const out: OptOut[] = [];
2450
+ OPT_OUT_DIRECTIVE_RE.lastIndex = 0;
2451
+ for (const match of source.matchAll(OPT_OUT_DIRECTIVE_RE)) {
2452
+ const at = match.index ?? 0;
2453
+ if (scannable[at] === source[at]) continue; // not inside a comment
2454
+ if (source[at - 1] === "`") continue; // documentation quoting the syntax
2455
+ const lineStart = source.lastIndexOf("\n", at) + 1;
2456
+ const reason = optOutReason(source, lineStart, at + match[0].length)
2457
+ .replace(/\s+/g, " ")
2458
+ .replace(/^[\s—–:-]+/, "")
2459
+ .trim();
2460
+ out.push({ rule: match[1] ?? "", reason, line: lineOf(source, at) });
2461
+ }
2462
+ return out;
2463
+ }
2464
+
2465
+ const wordCount = (text: string) => (text.match(/[A-Za-z0-9][\w'’.-]*/g) ?? []).length;
2466
+
2467
+ /** Why an opt-out's reason does not qualify, or `null` when it does. */
2468
+ export function weakOptOutReason(rule: string, reason: string): string | null {
2469
+ if (!(rule in RULES)) return `names no rule "${rule}", so it suppresses nothing`;
2470
+ const cause = reason.match(OPT_OUT_CAUSE_RE);
2471
+ if (!cause) return `starts with no cause — lead with ${OPT_OUT_CAUSES.join(" | ")} | task <n>`;
2472
+ if (wordCount(reason.slice(cause[0].length)) < OPT_OUT_MIN_WORDS)
2473
+ return `says why in fewer than ${OPT_OUT_MIN_WORDS} words after its cause`;
2474
+ return null;
2475
+ }
2476
+
2477
+ const optOutCount: Rule = {
2478
+ name: "opt-out-count",
2479
+ description:
2480
+ "Task 2381: every `guardrails-ignore` and `guardrails-file-allow` is counted, so an app's opt-outs can only fall. Use the shared component instead of adding one; a genuine new exception raises the row in a commit carrying `allow-raise: <task#>`.",
2481
+ check(filePath, source) {
2482
+ if (isTestSource(filePath.replace(/\\/g, "/"))) return [];
2483
+ const lines = source.split("\n");
2484
+ const perLine = findOptOuts(source).map((o) => ({ file: filePath, line: o.line, snippet: lines[o.line - 1]?.trim() ?? "" }));
2485
+ const fileWide = parseFileAllows(source).allows.map((a) => ({
2486
+ file: filePath,
2487
+ line: a.line,
2488
+ snippet: lines[a.line - 1]?.trim() ?? "",
2489
+ }));
2490
+ return [...perLine, ...fileWide];
2491
+ },
2492
+ };
2493
+
2494
+ const optOutNeedsCause: Rule = {
2495
+ name: "opt-out-needs-cause",
2496
+ description: `Task 2381: an opt-out reads \`guardrails-ignore <rule>: <cause> — <at least ${OPT_OUT_MIN_WORDS} words of why>\`, the cause one of ${OPT_OUT_CAUSES.join(", ")} or a task number (\`task 2381\`). A file-wide allowance's category is its cause; its reason still needs ${OPT_OUT_MIN_WORDS} words.`,
2497
+ check(filePath, source) {
2498
+ if (isTestSource(filePath.replace(/\\/g, "/"))) return [];
2499
+ const lines = source.split("\n");
2500
+ const out: Violation[] = [];
2501
+ for (const optOut of findOptOuts(source)) {
2502
+ const weak = weakOptOutReason(optOut.rule, optOut.reason);
2503
+ if (weak) out.push({ file: filePath, line: optOut.line, snippet: `${lines[optOut.line - 1]?.trim().slice(0, 120) ?? ""} ⟵ ${weak}` });
2504
+ }
2505
+ for (const allow of parseFileAllows(source).allows) {
2506
+ if (wordCount(allow.reason) >= OPT_OUT_MIN_WORDS) continue;
2507
+ out.push({
2508
+ file: filePath,
2509
+ line: allow.line,
2510
+ snippet: `${lines[allow.line - 1]?.trim().slice(0, 120) ?? ""} ⟵ file-wide reason under ${OPT_OUT_MIN_WORDS} words`,
2511
+ });
2512
+ }
2513
+ return out;
2514
+ },
2515
+ };
2516
+
2209
2517
  export const RULES: Record<string, Rule> = {
2210
2518
  [noRawActionButton.name]: noRawActionButton,
2211
2519
  [noRawOverflow.name]: noRawOverflow,
@@ -2231,6 +2539,9 @@ export const RULES: Record<string, Rule> = {
2231
2539
  [noRawPopover.name]: noRawPopover,
2232
2540
  [noHandRolledScrollMemory.name]: noHandRolledScrollMemory,
2233
2541
  [noHandRolledSurface.name]: noHandRolledSurface,
2542
+ [noInlineStyle.name]: noInlineStyle,
2543
+ [optOutCount.name]: optOutCount,
2544
+ [optOutNeedsCause.name]: optOutNeedsCause,
2234
2545
  };
2235
2546
 
2236
2547
  /**
@@ -2296,6 +2607,11 @@ export function parseFileAllows(source: string): { allows: FileAllow[]; warnings
2296
2607
  );
2297
2608
  continue;
2298
2609
  }
2610
+ const meta = [...rules].filter((r) => META_RULES.has(r));
2611
+ if (meta.length > 0) {
2612
+ warnings.push(`line ${line}: ${meta.join(", ")} cannot be file-allowed — an exemption from counting exemptions is the loophole they close; allowance ignored`);
2613
+ continue;
2614
+ }
2299
2615
  if (reason.length < FILE_ALLOW_MIN_REASON) {
2300
2616
  warnings.push(`line ${line}: reason too short — explain why this file qualifies; allowance ignored`);
2301
2617
  continue;
@@ -2401,7 +2717,7 @@ function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[])
2401
2717
  // is a second source of truth that drifts the moment the belt publishes, so the list
2402
2718
  // is read off the react index files of the INSTALLED package — this file's own
2403
2719
  // package, whatever version resolved. `--reimplements <Name,…>` stays for a caller
2404
- // with a list of its own (the CURSEDBELT_INVENTORY.md path in the rule's header).
2720
+ // with a list of its own.
2405
2721
  const BELT_REACT_ROOT = join(import.meta.dir, "..", "src", "react");
2406
2722
 
2407
2723
  export function beltExportedComponentNames(reactRoot: string = BELT_REACT_ROOT): string[] {
@@ -2461,9 +2777,8 @@ function main() {
2461
2777
  i++;
2462
2778
  continue;
2463
2779
  }
2464
- // The inventoried component names `no-local-reimplementation` looks for. Passed
2465
- // in rather than hardcoded so the list lives beside the inventory it comes from
2466
- // (fleet/standards/CURSEDBELT_INVENTORY.md) — see that rule's header.
2780
+ // The component names `no-local-reimplementation` looks for, when a caller keeps a
2781
+ // list of its own — `--reimplements-from-belt` is the usual source.
2467
2782
  if (rest[i] === "--reimplements") {
2468
2783
  setReimplementedNames((rest[i + 1] ?? "").split(",").map((n) => n.trim()).filter(Boolean));
2469
2784
  i++;
@@ -2494,6 +2809,7 @@ function main() {
2494
2809
  }
2495
2810
 
2496
2811
  let flatBaseline: FlatBaseline | undefined;
2812
+ const missingRows = new Set<string>();
2497
2813
  if (baselinePath) {
2498
2814
  if (!existsSync(baselinePath)) {
2499
2815
  console.error(`--baseline ${baselinePath}: no such file (relative to ${process.cwd()})`);
@@ -2509,7 +2825,11 @@ function main() {
2509
2825
  if (complaints.length > 0) {
2510
2826
  console.error(`${baselinePath} and the rules being scanned disagree:`);
2511
2827
  for (const complaint of complaints) console.error(` ${complaint}`);
2512
- process.exit(2);
2828
+ // A row with no rule stops here. A rule with no row — what a belt bump that adds a
2829
+ // rule produces — is still scanned, so the run prints the exact row to record
2830
+ // instead of making the bumping agent find the count by hand; it exits 2 at the end.
2831
+ if (Object.keys(flatBaseline).some((name) => !ruleNames.includes(name))) process.exit(2);
2832
+ for (const name of ruleNames) if (!(name in flatBaseline)) missingRows.add(name);
2513
2833
  }
2514
2834
  }
2515
2835
 
@@ -2601,6 +2921,10 @@ function main() {
2601
2921
  violations.push(...found.filter((v) => !v.absolute));
2602
2922
  }
2603
2923
 
2924
+ if (missingRows.has(rule.name)) {
2925
+ console.error(`✗ ${rule.name}: no row in ${baselinePath} — record it: "${rule.name}": ${violations.length}`);
2926
+ continue;
2927
+ }
2604
2928
  const allowedByBaseline = scopeBaseline[rule.name] ?? 0;
2605
2929
  if (violations.length === allowedByBaseline) {
2606
2930
  console.log(
@@ -2669,6 +2993,7 @@ function main() {
2669
2993
  console.error(`✗ ${relative(process.cwd(), v.file)}:${v.line} ${v.snippet}\n ${v.absolute}`);
2670
2994
  }
2671
2995
 
2996
+ if (missingRows.size > 0) process.exit(2);
2672
2997
  process.exit(anyExceededBaseline || absolutes.length > 0 ? 1 : 0);
2673
2998
  }
2674
2999
 
package/scripts/paths.ts CHANGED
@@ -38,7 +38,7 @@ import { dirname, join, resolve } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
 
40
40
  /** The generation's tools this repo runs over its own tree, in the order they should fail. */
41
- const TOOLS = ["check-paths.ts", "check-doc-citations.ts", "check-gate-graph.ts", "check-test-spawn.ts"];
41
+ const TOOLS = ["check-paths.ts", "check-doc-citations.ts", "check-gate-graph.ts", "check-test-spawn.ts", "check-install.ts"];
42
42
 
43
43
  /** How far up either walk looks before giving up — a bound, so a misuse is `null` in ms. */
44
44
  const MAX_HOPS = 16;
@@ -16,6 +16,9 @@ export interface ConfirmDialogProps {
16
16
  destructive?: boolean;
17
17
  /** Spinner on the confirm button while an async `onConfirm` runs (caller-controlled). */
18
18
  loading?: boolean;
19
+ /** Added to the dialog's content box — an app that scopes the belt's preflight under a class
20
+ * (binary-server's admin: `cb-scope`) passes it here, because the dialog portals to <body>. */
21
+ className?: string;
19
22
  }
20
23
 
21
24
  /**
@@ -36,6 +39,7 @@ export function ConfirmDialog({
36
39
  cancelLabel = 'Cancel',
37
40
  destructive = false,
38
41
  loading = false,
42
+ className,
39
43
  }: ConfirmDialogProps) {
40
44
  const contentRef = useRef<HTMLDivElement>(null);
41
45
 
@@ -53,6 +57,7 @@ export function ConfirmDialog({
53
57
  'rounded-xl border border-border bg-popover p-6 text-popover-foreground shadow-overlay',
54
58
  'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
55
59
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
60
+ className,
56
61
  )}
57
62
  // Land focus on the safe button for destructive actions; otherwise on confirm.
58
63
  // Button isn't a forwardRef component, so target it by data attribute.