cursedbelt 4.1.1 → 4.4.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 (108) hide show
  1. package/dist/react/components/Autocomplete.d.ts.map +1 -1
  2. package/dist/react/components/Autocomplete.js +1 -1
  3. package/dist/react/components/Autocomplete.js.map +1 -1
  4. package/dist/react/components/ComboboxField.d.ts.map +1 -1
  5. package/dist/react/components/ComboboxField.js +1 -1
  6. package/dist/react/components/ComboboxField.js.map +1 -1
  7. package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
  8. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  9. package/dist/react/media/CanvasVideoPlayer.js +4 -3
  10. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  11. package/dist/react/media/FrameGrabber.d.ts +16 -2
  12. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  13. package/dist/react/media/FrameGrabber.js +68 -43
  14. package/dist/react/media/FrameGrabber.js.map +1 -1
  15. package/dist/react/media/PictureEditor.d.ts +17 -2
  16. package/dist/react/media/PictureEditor.d.ts.map +1 -1
  17. package/dist/react/media/PictureEditor.js +33 -7
  18. package/dist/react/media/PictureEditor.js.map +1 -1
  19. package/dist/react/media/VideoChapterEditor.d.ts +17 -2
  20. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  21. package/dist/react/media/VideoChapterEditor.js +21 -3
  22. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  23. package/dist/react/media/VideoCutEditor.d.ts +16 -2
  24. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  25. package/dist/react/media/VideoCutEditor.js +21 -3
  26. package/dist/react/media/VideoCutEditor.js.map +1 -1
  27. package/dist/react/media/VideoPlayer.d.ts +37 -9
  28. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  29. package/dist/react/media/VideoPlayer.js +130 -39
  30. package/dist/react/media/VideoPlayer.js.map +1 -1
  31. package/dist/react/media/hlsSource.d.ts +24 -3
  32. package/dist/react/media/hlsSource.d.ts.map +1 -1
  33. package/dist/react/media/hlsSource.js +109 -8
  34. package/dist/react/media/hlsSource.js.map +1 -1
  35. package/dist/react/media/pictureEditor.css +2 -0
  36. package/dist/react/media/sourceFault.d.ts +146 -0
  37. package/dist/react/media/sourceFault.d.ts.map +1 -0
  38. package/dist/react/media/sourceFault.js +139 -0
  39. package/dist/react/media/sourceFault.js.map +1 -0
  40. package/dist/react/media/videoChapterEditor.css +19 -0
  41. package/dist/react/media/videoCutEditor.css +22 -0
  42. package/dist/react/media-gallery/MediaGallery.d.ts +21 -3
  43. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  44. package/dist/react/media-gallery/MediaGallery.js +178 -17
  45. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  46. package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
  47. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  48. package/dist/react/media-gallery/galleryItemMedia.js +27 -3
  49. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  50. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  51. package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
  52. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  53. package/dist/react/media-gallery/types.d.ts +23 -0
  54. package/dist/react/media-gallery/types.d.ts.map +1 -1
  55. package/dist/scripts/guardrailsEnforce.d.ts +1 -0
  56. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  57. package/dist/styles-areas/analytics.css +1 -1
  58. package/dist/styles-areas/data-table.css +1 -1
  59. package/dist/styles-areas/fields.css +1 -1
  60. package/dist/styles-areas/media-gallery.css +1 -1
  61. package/dist/styles-areas/media.css +1 -1
  62. package/dist/styles-areas/wizard.css +1 -1
  63. package/package.json +51 -6
  64. package/scripts/demoServer.ts +211 -0
  65. package/scripts/gate.ts +101 -0
  66. package/scripts/guardrailsEnforce.spec.ts +313 -1
  67. package/scripts/guardrailsEnforce.ts +283 -26
  68. package/scripts/paths.ts +1 -1
  69. package/src/demoFixture.spec.ts +54 -10
  70. package/src/demoStaticServer.spec.ts +169 -0
  71. package/src/publishShape.spec.ts +79 -0
  72. package/src/react/components/Autocomplete.tsx +11 -1
  73. package/src/react/components/ComboboxField.spec.tsx +12 -0
  74. package/src/react/components/ComboboxField.tsx +11 -1
  75. package/src/react/media/CanvasVideoPlayer.tsx +11 -6
  76. package/src/react/media/FrameGrabber.spec.tsx +57 -1
  77. package/src/react/media/FrameGrabber.tsx +138 -84
  78. package/src/react/media/PictureEditor.spec.tsx +90 -0
  79. package/src/react/media/PictureEditor.tsx +78 -23
  80. package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
  81. package/src/react/media/VideoChapterEditor.tsx +64 -18
  82. package/src/react/media/VideoCutEditor.spec.tsx +44 -0
  83. package/src/react/media/VideoCutEditor.tsx +63 -18
  84. package/src/react/media/VideoPlayer.spec.tsx +101 -0
  85. package/src/react/media/VideoPlayer.tsx +221 -73
  86. package/src/react/media/hlsSource.answer.spec.ts +255 -0
  87. package/src/react/media/hlsSource.ts +136 -8
  88. package/src/react/media/pictureEditor.css +2 -0
  89. package/src/react/media/sourceFault.spec.tsx +224 -0
  90. package/src/react/media/sourceFault.tsx +220 -0
  91. package/src/react/media/videoChapterEditor.css +19 -0
  92. package/src/react/media/videoCutEditor.css +22 -0
  93. package/src/react/media-gallery/MediaGallery.spec.tsx +311 -0
  94. package/src/react/media-gallery/MediaGallery.tsx +382 -54
  95. package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
  96. package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
  97. package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
  98. package/src/react/media-gallery/types.ts +23 -0
  99. package/src/styles-areas/analytics.css +1 -1
  100. package/src/styles-areas/data-table.css +1 -1
  101. package/src/styles-areas/fields.css +1 -1
  102. package/src/styles-areas/media-gallery.css +1 -1
  103. package/src/styles-areas/media.css +1 -1
  104. package/src/styles-areas/wizard.css +1 -1
  105. package/src/testFilesRunInParallel.spec.ts +3 -3
  106. package/src/typecheckCachesAreSeparate.spec.ts +2 -2
  107. package/src/verifyGraph.spec.ts +75 -108
  108. package/scripts/verify.ts +0 -296
@@ -8,13 +8,31 @@
8
8
  // invoked from each package's `lint`/`done` script, exiting non-zero on violation).
9
9
  //
10
10
  // Usage:
11
- // bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name>]
12
- // [--reimplements <Name,Name,…>] [root...]
11
+ // bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
12
+ // [--scope <name> | --baseline <path>]
13
+ // [--reimplements <Name,Name,…> | --reimplements-from-belt]
14
+ // [--exclude <path>[,<path>…]] [root...]
13
15
  //
14
16
  // `--scope <name>` selects the ratchet baseline row (see "Ratchet baseline" below) for
15
17
  // the calling package — `cursedbelt` and `cursedshell` each pass their own scope name
16
18
  // from their `lint` script. Omitting `--scope` defaults every rule's baseline to 0.
17
19
  //
20
+ // A CONSUMING APP uses the other three instead, and needs no wrapper script of its own:
21
+ //
22
+ // "guardrails": "bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts <rules>
23
+ // --baseline scripts/guardrails-baseline.json --reimplements-from-belt src"
24
+ //
25
+ // --baseline <path> a flat `{ "<rule>": <count> }` ratchet in the CALLER's
26
+ // repo, TWO-SIDED — exceeding a row fails, and so does
27
+ // falling below one, which prints the number to paste.
28
+ // The rows and the scanned rules must agree exactly, both
29
+ // directions. See "a CONSUMER's ratchet" below.
30
+ // --reimplements-from-belt derive `no-local-reimplementation`'s name list from THIS
31
+ // package's own react index files, so no app hand-maintains
32
+ // 384 names. Fails closed on an empty list.
33
+ // --exclude <path>[,<path>…] drop build output that lives under a source root. Fails
34
+ // if it matches nothing.
35
+ //
18
36
  // Currently implemented rules:
19
37
  // no-raw-action-button — STANDARDS.md §4a: "a plugin or generated app must not render
20
38
  // a raw <button> for an action (a raw <button> is acceptable only for a purely
@@ -148,7 +166,7 @@
148
166
  // ignored (its violations still fire) and a warning is printed.
149
167
 
150
168
  import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
151
- import { basename, extname, join, relative } from "node:path";
169
+ import { basename, extname, join, relative, resolve } from "node:path";
152
170
 
153
171
  const IGNORE_DIR_NAMES = new Set(["node_modules", "dist", "dist-deploy", ".git", "build", "coverage"]);
154
172
  const SOURCE_EXTENSIONS = new Set([".tsx", ".jsx", ".css"]);
@@ -240,7 +258,24 @@ function skipQuoted(source: string, start: number): number {
240
258
  return i;
241
259
  }
242
260
 
243
- /** Slice from a `<button` (or similar) match to the end of its opening tag, tolerating
261
+ /** One opening tag, in the two forms its checks need. Both strings cover the SAME span
262
+ * and have the same length, so a line number or offset taken from one is valid in the
263
+ * other. */
264
+ interface OpeningTag {
265
+ /** Exactly as written, comments included — what a `guardrails-ignore <rule>:` check
266
+ * must read, since the thing it is looking for IS a comment. */
267
+ readonly text: string;
268
+ /** The same span with comment bodies blanked to spaces (newlines kept, so offsets and
269
+ * line numbers hold) — what an ATTRIBUTE check must read. An attribute check run over
270
+ * `text` is fooled by prose that merely QUOTES the attribute: a comment reading
271
+ * ``role="radio" + aria-checked says so to a screen reader`` matched
272
+ * `PRESENTATIONAL_MARKER_RE` and silently opted a raw `<button>` out of
273
+ * no-raw-action-button (task 42). Narrowing such a regex only moves the goalposts —
274
+ * the input was wrong, so the input is what changed. */
275
+ readonly code: string;
276
+ }
277
+
278
+ /** Read from a `<button` (or similar) match to the end of its opening tag, tolerating
244
279
  * `{...}` JS expressions in attribute values that may themselves contain `>`
245
280
  * (`onClick={() => …}`, which works because that `>` sits inside braces).
246
281
  *
@@ -248,30 +283,40 @@ function skipQuoted(source: string, start: number): number {
248
283
  * so it may contain an angle bracket ("a radio-like list rather than a `<select>`").
249
284
  * Those are skipped the same way `blankComments` skips them, because a `>` written in prose
250
285
  * used to end the tag here and make every attribute below it invisible — the
251
- * `guardrails-ignore` opt-out included. `blankComments` cannot simply be run first: the
252
- * opt-out checks read this slice and must see the ORIGINAL comment text (see the
253
- * no-raw-action-button check below), so the skip only suppresses TERMINATION; the comment
254
- * stays in what is returned. */
255
- function extractOpeningTag(source: string, startIndex: number): string {
286
+ * `guardrails-ignore` opt-out included. The skip therefore suppresses only TERMINATION;
287
+ * the comment text survives in `.text` for the opt-out checks, and is blanked in `.code`
288
+ * for the attribute checks. */
289
+ function readOpeningTag(source: string, startIndex: number): OpeningTag {
290
+ const blank = (span: string) => span.replaceAll(/[^\n]/g, " ");
256
291
  let i = startIndex;
257
292
  let braceDepth = 0;
293
+ let code = "";
258
294
  while (i < source.length) {
259
295
  const two = source.slice(i, i + 2);
260
296
  if (two === "//") {
261
297
  const end = source.indexOf("\n", i);
262
- i = end === -1 ? source.length : end;
298
+ const stop = end === -1 ? source.length : end;
299
+ code += blank(source.slice(i, stop));
300
+ i = stop;
263
301
  continue;
264
302
  }
265
303
  if (two === "/*") {
266
304
  const end = source.indexOf("*/", i + 2);
267
- i = end === -1 ? source.length : end + 2;
305
+ const stop = end === -1 ? source.length : end + 2;
306
+ code += blank(source.slice(i, stop));
307
+ i = stop;
268
308
  continue;
269
309
  }
270
310
  const ch = source[i];
271
311
  if (ch === '"' || ch === "'" || ch === "`") {
272
- i = skipQuoted(source, i);
312
+ // A quoted attribute VALUE is real markup, not prose — `role="radio"` must
313
+ // survive into `.code`, which is the whole point of the attribute checks.
314
+ const stop = skipQuoted(source, i);
315
+ code += source.slice(i, stop);
316
+ i = stop;
273
317
  continue;
274
318
  }
319
+ code += ch;
275
320
  if (ch === "{") braceDepth++;
276
321
  else if (ch === "}") braceDepth--;
277
322
  else if (ch === ">" && braceDepth <= 0) {
@@ -280,7 +325,7 @@ function extractOpeningTag(source: string, startIndex: number): string {
280
325
  }
281
326
  i++;
282
327
  }
283
- return source.slice(startIndex, i);
328
+ return { text: source.slice(startIndex, i), code };
284
329
  }
285
330
 
286
331
  function lineOf(source: string, index: number): number {
@@ -427,10 +472,13 @@ const noRawActionButton: Rule = {
427
472
  const scannable = blankNonMarkupText(source);
428
473
  RAW_BUTTON_RE.lastIndex = 0;
429
474
  for (const match of scannable.matchAll(RAW_BUTTON_RE)) {
430
- // Re-extract the tag from the ORIGINAL source so opt-out comments (which were
431
- // just blanked out of `scannable`) are still visible to the ignore checks.
432
- const tag = extractOpeningTag(source, match.index);
433
- if (IGNORE_COMMENT_RE.test(tag) || PRESENTATIONAL_MARKER_RE.test(tag)) continue;
475
+ // Re-read the tag from the ORIGINAL source so opt-out comments (which were
476
+ // just blanked out of `scannable`) are still visible to the ignore check.
477
+ // The two halves take DIFFERENT inputs, and that is the point: the ignore
478
+ // check is looking for a comment, the marker check for a real attribute, so
479
+ // prose quoting `role="radio"` opts nothing out.
480
+ const tag = readOpeningTag(source, match.index);
481
+ if (IGNORE_COMMENT_RE.test(tag.text) || PRESENTATIONAL_MARKER_RE.test(tag.code)) continue;
434
482
  const line = lineOf(source, match.index);
435
483
  violations.push({
436
484
  file: filePath,
@@ -492,9 +540,10 @@ const noRawInputs: Rule = {
492
540
  const scannable = blankNonMarkupText(source);
493
541
  RAW_INPUT_RE.lastIndex = 0;
494
542
  for (const match of scannable.matchAll(RAW_INPUT_RE)) {
495
- const tag = extractOpeningTag(source, match.index);
543
+ const tag = readOpeningTag(source, match.index);
496
544
  const ignoreRe = /guardrails-ignore\s+no-raw-inputs\s*:/;
497
- if (ignoreRe.test(tag)) continue;
545
+ // `.text`: this rule's only opt-out IS a comment, so it reads the comments.
546
+ if (ignoreRe.test(tag.text)) continue;
498
547
  const line = lineOf(source, match.index);
499
548
  violations.push({
500
549
  file: filePath,
@@ -820,6 +869,12 @@ const ISDIRTY_PROP_RE = /\bisDirty\s*=/;
820
869
  * primitive. The `(?<!\[)` matters — `body().querySelector('[role="dialog"]')`
821
870
  * contains the same characters and is a test READING a dialog, not declaring one;
822
871
  * without it every dialog spec in the fleet is a violation.
872
+ *
873
+ * 🔴 Run it over COMMENT-BLANKED source only (`blankComments`, as `check` does below),
874
+ * never over raw text. It is an attribute regex, so prose quoting `role="dialog"` reads
875
+ * to it as a declaration — the same defect that let a quoted `role="radio"` suppress
876
+ * no-raw-action-button (task 42, see `OpeningTag`). Here it would fire the other way, on
877
+ * a comment explaining why a dialog role is wrong.
823
878
  */
824
879
  const HANDROLLED_DIALOG_RE =
825
880
  /(?<!\[)\brole\s*=\s*(?:['"]dialog['"]|\{\s*['"]dialog['"]\s*\})|<DialogContent\b/g;
@@ -1041,9 +1096,12 @@ const noRawTitleAttr: Rule = {
1041
1096
  // to delete an a11y requirement. (unit 11)
1042
1097
  if (match[1] === "iframe") continue;
1043
1098
  const index = match.index ?? 0;
1044
- const tag = extractOpeningTag(source, index);
1045
- if (!TITLE_ATTR_RE.test(tag)) continue;
1046
- if (TITLE_ATTR_IGNORE_RE.test(tag)) continue;
1099
+ const tag = readOpeningTag(source, index);
1100
+ // Same split as no-raw-action-button, the other way up: reading comments with
1101
+ // an ATTRIBUTE regex here flags an element that has no `title` at all because
1102
+ // a comment in its attribute list mentions one ("not a title= hover hint").
1103
+ if (!TITLE_ATTR_RE.test(tag.code)) continue;
1104
+ if (TITLE_ATTR_IGNORE_RE.test(tag.text)) continue;
1047
1105
  const line = lineOf(source, index);
1048
1106
  const windowStart = Math.max(0, line - 3);
1049
1107
  if (TITLE_ATTR_IGNORE_RE.test(lines.slice(windowStart, line).join("\n"))) continue;
@@ -1678,18 +1736,122 @@ function loadBaseline(): Baseline {
1678
1736
  }
1679
1737
  }
1680
1738
 
1739
+ // ── `--baseline <path>`: a CONSUMER's ratchet, in the consumer's own repo ───────
1740
+ // The packaged baseline above cannot be a consuming app's: it lives inside
1741
+ // `node_modules/cursedbelt`, so nobody can check their debt ceiling in beside their
1742
+ // code. Nine apps each carried an identical ~140-line wrapper to work around that
1743
+ // (`apps/*/scripts/guardrails.ts`, deleted 2026-09-18 by tasks/…/202-*.md); this flag
1744
+ // is that wrapper, folded into the one place it belongs.
1745
+ //
1746
+ // The file is FLAT — `{ "<rule>": <count> }`, no scope level, because a repo's
1747
+ // baseline is about one repo. A `"$comment"` key is reserved for the why (desk's row
1748
+ // list carries fifty lines of it) and is the only non-numeric value tolerated:
1749
+ // anything else non-numeric is a typo in a row, and a typo read as `?? 0` would demand
1750
+ // a whole inherited pile in one commit, so it is refused by name instead.
1751
+ //
1752
+ // 🔴 It is TWO-SIDED, and the packaged `--scope` baseline is not. Deliberate, and the
1753
+ // asymmetry is the point: `--scope` names a row the CALLER cannot edit (it ships inside
1754
+ // the package), so failing a consumer for a number only a cursedbelt release can lower
1755
+ // would be a gate nobody can turn green. `--baseline` names a file in the caller's own
1756
+ // repo, which they lower in the same commit — so falling below a row fails, printing
1757
+ // the number to paste. A one-sided ratchet accumulates slack: fix forty colours, leave
1758
+ // the row at 353, and forty may quietly come back. Same idiom as the generation's
1759
+ // `check-paths` baseline — inherited debt only falls.
1760
+ type FlatBaseline = Record<string, number>;
1761
+
1762
+ const BASELINE_COMMENT_KEY = "$comment";
1763
+
1764
+ /** Parse a flat `{rule: count}` baseline, refusing anything that is not a count. */
1765
+ function parseFlatBaseline(json: string, label: string): FlatBaseline {
1766
+ let raw: unknown;
1767
+ try {
1768
+ raw = JSON.parse(json);
1769
+ } catch (error) {
1770
+ throw new Error(`${label}: invalid JSON — ${(error as Error).message}`);
1771
+ }
1772
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1773
+ throw new Error(`${label}: expected an object of {"<rule>": <count>}`);
1774
+ }
1775
+ const rows: FlatBaseline = {};
1776
+ for (const [rule, value] of Object.entries(raw as Record<string, unknown>)) {
1777
+ if (rule === BASELINE_COMMENT_KEY) continue;
1778
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1779
+ throw new Error(`${label}: "${rule}" is ${JSON.stringify(value)}, not a violation count`);
1780
+ }
1781
+ rows[rule] = value;
1782
+ }
1783
+ return rows;
1784
+ }
1785
+
1786
+ /**
1787
+ * Both directions of "the rows and the rules must agree", which is the hole this
1788
+ * closes for the wrappers that used to do it by hand. A rule with no row is scored 0
1789
+ * and demands its whole inherited pile in one commit; a row with no rule is coverage
1790
+ * that is not there — the same defect as a `check-paths` baseline entry matching
1791
+ * nothing. Returns the complaints, empty when the two lists agree.
1792
+ */
1793
+ function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
1794
+ const complaints: string[] = [];
1795
+ const missing = ruleNames.filter((name) => !(name in rows));
1796
+ const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name));
1797
+ if (missing.length > 0) complaints.push(`no row for rule(s) being scanned: ${missing.join(", ")}`);
1798
+ if (orphans.length > 0) complaints.push(`row(s) for rules nobody scans: ${orphans.join(", ")}`);
1799
+ return complaints;
1800
+ }
1801
+
1802
+ // ── `--reimplements-from-belt`: the name list, off this package's own exports ───
1803
+ // `no-local-reimplementation` matches app-declared names against the ones cursedbelt
1804
+ // exports (384 of them as this lands). A hand-kept copy of a dependency's export list
1805
+ // is a second source of truth that drifts the moment the belt publishes, so the list
1806
+ // is read off the react index files of the INSTALLED package — this file's own
1807
+ // package, whatever version resolved. `--reimplements <Name,…>` stays for a caller
1808
+ // with a list of its own (the CURSEDBELT_INVENTORY.md path in the rule's header).
1809
+ const BELT_REACT_ROOT = join(import.meta.dir, "..", "src", "react");
1810
+
1811
+ export function beltExportedComponentNames(reactRoot: string = BELT_REACT_ROOT): string[] {
1812
+ const names = new Set<string>();
1813
+ const indexFiles: string[] = [];
1814
+ // `withFileTypes` rather than a `statSync` per entry — the react tree is ~1,300
1815
+ // entries, and one syscall each is 4s of a gate that is meant to be under a minute.
1816
+ const collect = (dir: string) => {
1817
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1818
+ const full = join(dir, entry.name);
1819
+ if (entry.isDirectory()) collect(full);
1820
+ else if (entry.name === "index.ts" || entry.name === "index.tsx") indexFiles.push(full);
1821
+ }
1822
+ };
1823
+ collect(reactRoot);
1824
+ for (const file of indexFiles) {
1825
+ const source = readFileSync(file, "utf8");
1826
+ for (const block of source.matchAll(/export\s*\{([^}]*)\}/gs)) {
1827
+ for (const part of block[1]?.split(",") ?? []) {
1828
+ const trimmed = part.trim();
1829
+ if (!trimmed || trimmed.startsWith("type ")) continue;
1830
+ const name = (trimmed.split(/\s+as\s+/).pop() ?? "").trim();
1831
+ if (/^[A-Z][A-Za-z0-9]*$/.test(name)) names.add(name);
1832
+ }
1833
+ }
1834
+ }
1835
+ return [...names];
1836
+ }
1837
+
1681
1838
  function main() {
1682
1839
  const [ruleArg, ...rest] = process.argv.slice(2);
1683
1840
  const ruleNames = ruleArg ? ruleArg.split(",") : [];
1684
1841
  const rules = ruleNames.map((n) => RULES[n]).filter((r): r is Rule => Boolean(r));
1685
1842
  if (ruleNames.length === 0 || rules.length !== ruleNames.length) {
1686
1843
  console.error(
1687
- `Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name>] [--reimplements <Name,…>] [root...]\nKnown rules: ${Object.keys(RULES).join(", ")}`,
1844
+ `Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name> | --baseline <path>]\n` +
1845
+ ` [--reimplements <Name,…> | --reimplements-from-belt] [--exclude <path>[,<path>…]] [root...]\n` +
1846
+ `Known rules: ${Object.keys(RULES).join(", ")}`,
1688
1847
  );
1689
1848
  process.exit(2);
1690
1849
  }
1691
1850
 
1692
1851
  let scope: string | undefined;
1852
+ let baselinePath: string | undefined;
1853
+ let fromBelt = false;
1854
+ const excludes: string[] = [];
1693
1855
  const roots: string[] = [];
1694
1856
  for (let i = 0; i < rest.length; i++) {
1695
1857
  if (rest[i] === "--scope") {
@@ -1697,6 +1859,12 @@ function main() {
1697
1859
  i++;
1698
1860
  continue;
1699
1861
  }
1862
+ // A flat `{rule: count}` ratchet in the CALLER's repo — see "a CONSUMER's ratchet".
1863
+ if (rest[i] === "--baseline") {
1864
+ baselinePath = rest[i + 1];
1865
+ i++;
1866
+ continue;
1867
+ }
1700
1868
  // The inventoried component names `no-local-reimplementation` looks for. Passed
1701
1869
  // in rather than hardcoded so the list lives beside the inventory it comes from
1702
1870
  // (fleet/standards/CURSEDBELT_INVENTORY.md) — see that rule's header.
@@ -1705,10 +1873,73 @@ function main() {
1705
1873
  i++;
1706
1874
  continue;
1707
1875
  }
1876
+ if (rest[i] === "--reimplements-from-belt") {
1877
+ fromBelt = true;
1878
+ continue;
1879
+ }
1880
+ // Build output that lives under a source root (desk's `src/generated/assets.ts` is
1881
+ // the compiled browser app, inlined as a module and rewritten by every build). Named
1882
+ // here rather than by listing every sibling root, because "name the siblings" fails
1883
+ // OPEN — a directory added later is silently unscanned.
1884
+ if (rest[i] === "--exclude") {
1885
+ for (const part of (rest[i + 1] ?? "").split(",")) {
1886
+ const trimmed = part.trim();
1887
+ if (trimmed) excludes.push(resolve(trimmed));
1888
+ }
1889
+ i++;
1890
+ continue;
1891
+ }
1708
1892
  roots.push(rest[i]);
1709
1893
  }
1894
+
1895
+ if (scope && baselinePath) {
1896
+ console.error("--scope and --baseline are two different ratchets; pass one.");
1897
+ process.exit(2);
1898
+ }
1899
+
1900
+ let flatBaseline: FlatBaseline | undefined;
1901
+ if (baselinePath) {
1902
+ if (!existsSync(baselinePath)) {
1903
+ console.error(`--baseline ${baselinePath}: no such file (relative to ${process.cwd()})`);
1904
+ process.exit(2);
1905
+ }
1906
+ try {
1907
+ flatBaseline = parseFlatBaseline(readFileSync(baselinePath, "utf8"), baselinePath);
1908
+ } catch (error) {
1909
+ console.error((error as Error).message);
1910
+ process.exit(2);
1911
+ }
1912
+ const complaints = baselineDisagreements(flatBaseline, ruleNames);
1913
+ if (complaints.length > 0) {
1914
+ console.error(`${baselinePath} and the rules being scanned disagree:`);
1915
+ for (const complaint of complaints) console.error(` ${complaint}`);
1916
+ process.exit(2);
1917
+ }
1918
+ }
1919
+
1920
+ if (fromBelt) {
1921
+ // 🔴 Fails CLOSED. An empty list makes `no-local-reimplementation` inert — it would
1922
+ // print "0 violations" for a check that never ran, and a ratchet row of 0 would then
1923
+ // look earned. If the react tree has moved, the run stops instead.
1924
+ let names: string[] = [];
1925
+ try {
1926
+ names = beltExportedComponentNames();
1927
+ } catch (error) {
1928
+ console.error(`--reimplements-from-belt: cannot read ${BELT_REACT_ROOT} — ${(error as Error).message}`);
1929
+ process.exit(2);
1930
+ }
1931
+ if (names.length === 0) {
1932
+ console.error(`--reimplements-from-belt: ${BELT_REACT_ROOT} exports no component names — refusing to scan against an empty list.`);
1933
+ process.exit(2);
1934
+ }
1935
+ setReimplementedNames(names);
1936
+ }
1937
+
1710
1938
  const baseline = loadBaseline();
1711
- const scopeBaseline = (scope && baseline[scope]) || {};
1939
+ const scopeBaseline: Record<string, number> = flatBaseline ?? (scope ? (baseline[scope] ?? {}) : {});
1940
+ // `--baseline` names a file the caller owns and can lower; `--scope` does not. That is
1941
+ // the whole reason one is two-sided and the other suggests.
1942
+ const twoSided = flatBaseline !== undefined;
1712
1943
 
1713
1944
  const searchRoots = roots.length > 0 ? roots : ["."];
1714
1945
  const files: string[] = [];
@@ -1721,6 +1952,17 @@ function main() {
1721
1952
  // every directory, e.g. a plugin package with no `apps/` dir)
1722
1953
  }
1723
1954
  }
1955
+ if (excludes.length > 0) {
1956
+ const kept = files.filter((file) => !excludes.some((dir) => resolve(file) === dir || resolve(file).startsWith(`${dir}/`)));
1957
+ if (kept.length === files.length) {
1958
+ // An exclusion matching nothing is either a typo or a directory that has moved,
1959
+ // and both read as "that build output is handled" while it is being scanned.
1960
+ console.error(`--exclude matched no scanned file: ${excludes.join(", ")}`);
1961
+ process.exit(2);
1962
+ }
1963
+ files.length = 0;
1964
+ files.push(...kept);
1965
+ }
1724
1966
 
1725
1967
  const sourcesByFile = new Map<string, string>();
1726
1968
  for (const file of files) sourcesByFile.set(file, readFileSync(file, "utf8"));
@@ -1751,13 +1993,28 @@ function main() {
1751
1993
  }
1752
1994
 
1753
1995
  const allowedByBaseline = scopeBaseline[rule.name] ?? 0;
1754
- if (violations.length <= allowedByBaseline) {
1996
+ if (violations.length === allowedByBaseline) {
1755
1997
  console.log(
1756
1998
  `✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
1757
1999
  );
1758
- if (violations.length < allowedByBaseline) {
2000
+ continue;
2001
+ }
2002
+ if (violations.length < allowedByBaseline) {
2003
+ // The ratchet's other side. Under `--scope` this is a suggestion printed at the
2004
+ // end; under `--baseline` it is a failure, because the caller owns that file and
2005
+ // leaving slack in it is how a row stops meaning anything.
2006
+ if (!twoSided) {
2007
+ console.log(
2008
+ `✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
2009
+ );
1759
2010
  lowerableBaselines[rule.name] = violations.length;
2011
+ continue;
1760
2012
  }
2013
+ anyExceededBaseline = true;
2014
+ console.error(
2015
+ `✗ ${rule.name}: ${violations.length} violation(s), below the baseline of ${allowedByBaseline} — lower "${rule.name}" ` +
2016
+ `to ${violations.length} in ${baselinePath} (the ratchet only falls)`,
2017
+ );
1761
2018
  continue;
1762
2019
  }
1763
2020
 
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"];
41
+ const TOOLS = ["check-paths.ts", "check-doc-citations.ts", "check-gate-graph.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;
@@ -4,6 +4,8 @@ import { resolve } from 'node:path';
4
4
  import {
5
5
  DEMO_COMMAND,
6
6
  DEMO_DEV_SCRIPT,
7
+ DEMO_DIST_DIR,
8
+ DEMO_HOST,
7
9
  DEMO_PACKAGE_DIR,
8
10
  DEMO_PORT,
9
11
  DEMO_ROOT,
@@ -12,7 +14,8 @@ import {
12
14
  } from '../demo/devServer';
13
15
  import { DEMO_TSCONFIG, derivePaths, readDemoTsconfig, renderPathsBlock } from '../demo/generatePaths';
14
16
  import pkg from '../package.json';
15
- import { STAGES } from '../scripts/verify';
17
+ // The declared graph, read as data — there is no scheduler in this repo to import from.
18
+ const STAGES: readonly { script: string }[] = (pkg as { gate?: { stages?: { script: string }[] } }).gate?.stages ?? [];
16
19
 
17
20
  /**
18
21
  * 🔴 The e2e suite must never be able to sit green while it drives nothing.
@@ -56,19 +59,38 @@ describe('the e2e fixture exists', () => {
56
59
  }
57
60
  expect(pkg.scripts.demo, 'the demo script must start vite on the demo config').toInclude('demo/vite.config.ts');
58
61
  // Playwright drives the BUILT bundle, not the dev server — see demo/devServer.ts.
59
- expect(pkg.scripts['demo:serve'], 'demo:serve must build before it previews').toInclude('demo:build');
60
- expect(pkg.scripts['demo:serve']).toInclude('vite preview');
62
+ expect(pkg.scripts['demo:serve'], 'demo:serve must build before it serves').toInclude('demo:build');
63
+ // 🔴 And it serves it from OUR server, never `vite preview` — which under Bun 1.4.2
64
+ // exits 1 with ERR_STREAM_WRITE_AFTER_END when a Playwright worker hangs up
65
+ // mid-response, reddening this gate about one run in four (task 388, 2026-09-18).
66
+ // Going back to it is a one-word edit, so it is a one-line assertion.
67
+ expect(pkg.scripts['demo:serve'], 'demo:serve must run scripts/demoServer.ts').toInclude('scripts/demoServer.ts');
68
+ expect(pkg.scripts['demo:serve'], '`vite preview` dies on a client hang-up under Bun').not.toInclude(
69
+ 'vite preview',
70
+ );
61
71
  });
62
72
 
63
- it('both servers serve the port the specs are pointed at', () => {
73
+ it('every server serves the port the specs are pointed at', () => {
64
74
  // vite silently walks to the next free port unless `strictPort` is set, which is
65
75
  // the same outage wearing a costume: a server that starts perfectly well and a
66
- // suite that times out against the port nobody is listening on.
76
+ // suite that times out against the port nobody is listening on. `Bun.serve` throws
77
+ // EADDRINUSE rather than walking, and `demoStaticServer.spec.ts` proves it does.
67
78
  const viteConfig = readFileSync(resolve(DEMO_ROOT, 'vite.config.ts'), 'utf8');
68
- expect(DEMO_URL).toBe(`http://localhost:${DEMO_PORT}`);
69
- // Once for `server` (dev), once for `preview` (what e2e drives).
70
- expect(viteConfig.match(/port: DEMO_PORT/g) ?? [], 'both server and preview must pin the port').toHaveLength(2);
71
- expect(viteConfig.match(/strictPort: true/g) ?? [], 'both must set strictPort').toHaveLength(2);
79
+ expect(DEMO_URL).toBe(`http://${DEMO_HOST}:${DEMO_PORT}`);
80
+ // 🔴 A literal address, never `localhost`. Measured 2026-09-18: two demo servers
81
+ // bound 4318 at the same time — one on `[::1]`, one on `127.0.0.1` — because
82
+ // `localhost` resolves to both and each process took whichever its resolver offered
83
+ // first. Neither bind collided, and which one a client reached was its own
84
+ // resolution order. A name here is a gate that can silently test a stale bundle.
85
+ expect(DEMO_HOST, 'the fixture must not be reached through a name that resolves twice').toBe('127.0.0.1');
86
+ expect(viteConfig, 'the dev server must bind the same one address').toInclude('host: DEMO_HOST');
87
+ // Once, for `server` — the dev server a human runs. There is no `preview` block any
88
+ // more, and a second match here would mean one grew back unread.
89
+ expect(viteConfig.match(/port: DEMO_PORT/g) ?? [], 'the dev server must pin the port').toHaveLength(1);
90
+ expect(viteConfig.match(/strictPort: true/g) ?? [], 'and must set strictPort').toHaveLength(1);
91
+ expect(viteConfig, 'the preview block is dead config now — e2e is served by scripts/demoServer.ts').not.toInclude(
92
+ 'preview:',
93
+ );
72
94
  });
73
95
 
74
96
  it('every page the specs navigate to is a real demo entry point', () => {
@@ -98,13 +120,35 @@ describe('the e2e fixture exists', () => {
98
120
  expect(unlisted, `demo/vite.config.ts's rollup input omits:\n ${unlisted.join('\n ')}`).toEqual([]);
99
121
  });
100
122
 
123
+ it('🔴 never reuses a server somebody else started', () => {
124
+ // The gate's meaning must not depend on what the previous agent in this checkout
125
+ // left running. `reuseExistingServer: !process.env.CI` meant exactly that: `CI` is
126
+ // unset on this Mac, so a stale listener on 4318 was silently adopted and the demo
127
+ // build was skipped. Two agents measured the same command at 34.8s and 45.0s on
128
+ // 2026-09-18 for that reason — the fast one tested a bundle nobody had rebuilt.
129
+ const config = readFileSync(resolve(e2eDir, 'playwright.config.ts'), 'utf8');
130
+ expect(config, 'the webServer must always be ours').toInclude('reuseExistingServer: false');
131
+ expect(config, 'reuse must not be conditional on an env var this machine never sets').not.toMatch(
132
+ /reuseExistingServer:\s*!?\s*process\.env/,
133
+ );
134
+ });
135
+
136
+ it('serves exactly the directory the demo build writes', () => {
137
+ // `scripts/demoServer.ts` serves DEMO_DIST_DIR; vite writes to `outDir` resolved
138
+ // against its `root`. A custom `outDir` would leave the server pointed at an empty
139
+ // directory and every spec 404ing against a 200-answering process.
140
+ const viteConfig = readFileSync(resolve(DEMO_ROOT, 'vite.config.ts'), 'utf8');
141
+ expect(DEMO_DIST_DIR).toBe(resolve(DEMO_ROOT, 'dist'));
142
+ expect(viteConfig, 'demoServer.ts assumes vite’s default outDir').not.toInclude('outDir');
143
+ });
144
+
101
145
  it('verify actually runs the suite', () => {
102
146
  // Everything above proves the fixture resolves. Only this proves anybody looks:
103
147
  // without it, `e2e` drops back out of the gate and this whole file becomes a
104
148
  // comment about a suite nothing runs.
105
149
  //
106
150
  // 🔴 `verify` stopped being an `&&` chain on 2026-09-15 and became a dependency
107
- // graph (`scripts/verify.ts`), so "the script mentions e2e" is no longer where the
151
+ // graph (the `gate` block in `package.json`), so "the script mentions e2e" is no longer where the
108
152
  // answer lives. Asking the graph is strictly stronger than asking the string was:
109
153
  // `verifyGraph.spec.ts` additionally pins that every OTHER stage of the old chain
110
154
  // is still in it, which the `toInclude` never checked for any of them.