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.
- package/dist/react/components/ConfirmDialog.d.ts +4 -1
- package/dist/react/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/react/components/ConfirmDialog.js +2 -2
- package/dist/react/components/ConfirmDialog.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +5 -1
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/PromptDialog.d.ts +4 -1
- package/dist/react/components/PromptDialog.d.ts.map +1 -1
- package/dist/react/components/PromptDialog.js +2 -2
- package/dist/react/components/PromptDialog.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +26 -1
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +182 -40
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +11 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/objectLeak.d.ts +23 -0
- package/dist/testing/objectLeak.d.ts.map +1 -0
- package/dist/testing/objectLeak.js +37 -0
- package/dist/testing/objectLeak.js.map +1 -0
- package/package.json +5 -5
- package/scripts/guardrails-baseline.json +4 -1
- package/scripts/guardrailsEnforce.spec.ts +163 -6
- package/scripts/guardrailsEnforce.ts +365 -40
- package/scripts/paths.ts +1 -1
- package/src/react/components/ConfirmDialog.tsx +5 -0
- package/src/react/components/DataTable/DataTable.spec.tsx +16 -0
- package/src/react/components/DataTable/DataTable.tsx +5 -1
- package/src/react/components/PromptDialog.spec.tsx +29 -1
- package/src/react/components/PromptDialog.tsx +5 -0
- package/src/react/file-tree/FileTree.spec.tsx +205 -0
- package/src/react/file-tree/FileTree.tsx +220 -38
- package/src/testing/index.ts +3 -0
- package/src/testing/objectLeak.spec.ts +21 -0
- 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 —
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
174
|
-
// "
|
|
175
|
-
//
|
|
176
|
-
//
|
|
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
|
-
'
|
|
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)
|
|
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 (
|
|
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
|
-
"
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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)";
|
|
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 (
|
|
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
|
|
1298
|
-
// `--reimplements <Name,Name
|
|
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
|
-
"
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
|
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
|
|
2465
|
-
//
|
|
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
|
-
|
|
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.
|