cursedbelt 3.0.2 → 4.0.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 (250) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +111 -31
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/styleAreas.ts +585 -0
  151. package/scripts/verify.ts +5 -0
  152. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  153. package/src/docsMatchTheSplit.spec.ts +98 -0
  154. package/src/fixtureAppCss.spec.ts +177 -0
  155. package/src/namedSubpathsResolve.spec.ts +35 -0
  156. package/src/publishShape.spec.ts +67 -0
  157. package/src/react/components/ListToolbar.spec.tsx +172 -0
  158. package/src/react/components/ListToolbar.tsx +151 -0
  159. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  160. package/src/react/file-tree/FileTree.tsx +1459 -309
  161. package/src/react/file-tree/fileTree.css +151 -0
  162. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  163. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  164. package/src/react/file-tree/index.ts +21 -0
  165. package/src/react/index.ts +1 -0
  166. package/src/react/lib/form.ts +51 -8
  167. package/src/react/media/DenseGalleryShell.tsx +108 -36
  168. package/src/react/media/FocusViewer.tsx +15 -8
  169. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  170. package/src/react/media/FrameGrabber.tsx +67 -0
  171. package/src/react/media/MediaResize.spec.tsx +84 -14
  172. package/src/react/media/MediaResize.tsx +51 -19
  173. package/src/react/media/VideoChapterEditor.tsx +428 -0
  174. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  175. package/src/react/media/VideoPlayer.tsx +158 -11
  176. package/src/react/media/VideoTurner.spec.tsx +67 -0
  177. package/src/react/media/VideoTurner.tsx +70 -15
  178. package/src/react/media/denseGallery.css +104 -46
  179. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  180. package/src/react/media/hlsSource.spec.ts +8 -0
  181. package/src/react/media/hlsSource.ts +189 -1
  182. package/src/react/media/index.ts +14 -0
  183. package/src/react/media/mediaResize.css +127 -54
  184. package/src/react/media/pictureEditor.css +52 -6
  185. package/src/react/media/videoChapterEditor.css +203 -0
  186. package/src/react/media/videoChapters.spec.ts +176 -0
  187. package/src/react/media/videoChapters.ts +181 -0
  188. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  189. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  190. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  191. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  192. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  193. package/src/react/media-gallery/index.ts +7 -0
  194. package/src/react/media-gallery/mediaGallery.css +180 -4
  195. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  196. package/src/react/media-gallery/mediaMeta.ts +101 -0
  197. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  198. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  199. package/src/react/media-gallery/types.ts +34 -0
  200. package/src/shippedFilesAreTracked.spec.ts +69 -0
  201. package/src/styles-areas/activity-bar.css +30 -0
  202. package/src/styles-areas/analytics.css +672 -0
  203. package/src/styles-areas/auth.css +154 -0
  204. package/src/styles-areas/calendar.css +416 -0
  205. package/src/styles-areas/chart-table.css +41 -0
  206. package/src/styles-areas/charts.css +89 -0
  207. package/src/styles-areas/chat.css +364 -0
  208. package/src/styles-areas/clipboard.css +26 -0
  209. package/src/styles-areas/code-editor.css +130 -0
  210. package/src/styles-areas/companion-link.css +70 -0
  211. package/src/styles-areas/core.css +2503 -0
  212. package/src/styles-areas/dashboard-grid.css +84 -0
  213. package/src/styles-areas/data-table.css +657 -0
  214. package/src/styles-areas/deep-link.css +26 -0
  215. package/src/styles-areas/diff-viewer.css +30 -0
  216. package/src/styles-areas/disk-usage.css +262 -0
  217. package/src/styles-areas/emoji.css +154 -0
  218. package/src/styles-areas/fields.css +582 -0
  219. package/src/styles-areas/file-tree.css +33 -0
  220. package/src/styles-areas/filter-rail.css +235 -0
  221. package/src/styles-areas/folder-tree.css +219 -0
  222. package/src/styles-areas/keep-awake.css +26 -0
  223. package/src/styles-areas/layout-engine.css +239 -0
  224. package/src/styles-areas/markdown.css +87 -0
  225. package/src/styles-areas/master-detail.css +239 -0
  226. package/src/styles-areas/master-lock.css +26 -0
  227. package/src/styles-areas/media-gallery.css +351 -0
  228. package/src/styles-areas/media.css +658 -0
  229. package/src/styles-areas/nav.css +488 -0
  230. package/src/styles-areas/notifications.css +245 -0
  231. package/src/styles-areas/overlays.css +133 -0
  232. package/src/styles-areas/palette.css +140 -0
  233. package/src/styles-areas/pdf-viewer.css +47 -0
  234. package/src/styles-areas/problem-detail.css +97 -0
  235. package/src/styles-areas/rich-text.css +167 -0
  236. package/src/styles-areas/sharing.css +153 -0
  237. package/src/styles-areas/spreadsheet.css +37 -0
  238. package/src/styles-areas/stats.css +281 -0
  239. package/src/styles-areas/test-report.css +133 -0
  240. package/src/styles-areas/virtual.css +30 -0
  241. package/src/styles-areas/wizard.css +381 -0
  242. package/src/styles-areas/workbench.css +543 -0
  243. package/src/styles-areas/workbook-viewer.css +44 -0
  244. package/src/styles-static.css +34 -23
  245. package/src/styles.css +34 -23
  246. package/src/stylesAreas.spec.ts +247 -0
  247. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  248. package/src/testFilesRunInParallel.spec.ts +79 -0
  249. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  250. package/src/verifyGraph.spec.ts +6 -0
package/src/styles.css CHANGED
@@ -6,29 +6,32 @@
6
6
  * This `@source` makes Tailwind v4 scan cursedbelt's source files so the utility
7
7
  * classes used by its components are generated in the app's stylesheet.
8
8
  *
9
- * ── Two ways in, and which one an app wants ──────────────────────────────────
9
+ * ── Three ways in, and which one an app wants ────────────────────────────────
10
10
  * `@source "."` is the expensive half: it points Tailwind's JIT at cursedbelt's
11
11
  * ENTIRE source tree, so the app emits the union of the whole design system
12
12
  * whether it renders three components or eighty, and its own build walks 827
13
13
  * files it does not own. Everything else in this file is CSS the components
14
14
  * genuinely need and no app can do without.
15
15
  *
16
- * A CSS `@import` takes the whole file, so the split is two more subpaths:
16
+ * A CSS `@import` takes the whole file, so the split is more subpaths:
17
17
  *
18
- * cursedbelt/styles.css this file — the real CSS + the `@source` scan
19
- * cursedbelt/styles-static.css the same file MINUS the `@source` line
20
- * cursedbelt/styles-utilities.css the utilities that `@source` line would have
21
- * produced, precompiled at cursedbelt's build
18
+ * cursedbelt/styles.css this file — the real CSS + the `@source` scan
19
+ * cursedbelt/styles-static.css the same file MINUS the `@source` line
20
+ * cursedbelt/styles-utilities.css the utilities that `@source` line would have
21
+ * produced, precompiled at cursedbelt's build
22
+ * cursedbelt/styles-areas/<area>.css the same utilities, split by component family
22
23
  *
23
- * The second two are a PAIR and neither is usable alone — `styles-static.css` on
24
- * its own gets the tooltip pointer-events fix, the Progress keyframes, the
25
- * lightbox bridge, the ambient/glass layer and `tw-animate-css`, and emits none
26
- * of cursedbelt's utility classes, i.e. an unstyled app:
24
+ * `styles-static.css` is never usable alone — on its own it gets the tooltip
25
+ * pointer-events fix, the Progress keyframes, the lightbox bridge, the
26
+ * ambient/glass layer and `tw-animate-css`, and emits none of cursedbelt's
27
+ * utility classes, i.e. an unstyled app. It pairs with EITHER of the other two:
27
28
  *
28
- * @import "tailwindcss";
29
- * @import "cursedbelt/styles-static.css";
30
- * @import "cursedbelt/styles-utilities.css";
31
- * @import "cursedbelt/theme.css";
29
+ * @import "tailwindcss"; @import "tailwindcss";
30
+ * @import "cursedbelt/styles-static.css"; @import "cursedbelt/styles-static.css";
31
+ * @import "cursedbelt/styles-utilities.css"; @import "cursedbelt/styles-areas/core.css";
32
+ * @import "cursedbelt/theme.css"; @import "cursedbelt/styles-areas/data-table.css";
33
+ * @import "cursedbelt/theme.css";
34
+ * the whole design system what this app renders
32
35
  *
33
36
  * 🔴 Do NOT hand-list cursedbelt's component files to get the utilities back.
34
37
  * Tailwind v4 resolves every `@source` to a DIRECTORY and scans it recursively —
@@ -38,22 +41,30 @@
38
41
  * into this package's source tree is also a `file:` dependency wearing a
39
42
  * stylesheet's clothes, and it buys nothing: it is the thing the previous
40
43
  * generation's 29-line-per-app arrangement is remembered for, and those 29 lines
41
- * were never file-level scoping either.
44
+ * were never file-level scoping either. Three apps hand-maintained such a list
45
+ * and one of them ran to 173 lines; all three were deleted, because every one of
46
+ * them was a directory list wearing a costume.
42
47
  *
43
- * The pair is not SMALLER than this file — measured, both routes emit the same
44
- * rules within 54 bytes. What it buys is a build that scans nothing of another
45
- * package's source tree, and a `styles-static.css` that finally has a correct
46
- * use. The measurements, and what would actually shrink a consumer, are in the
47
- * header of `scripts/generateUtilityStyles.ts`.
48
+ * `styles-utilities.css` is not SMALLER than this file — measured, both routes
49
+ * emit the same rules within 54 bytes; the union is the union however it is
50
+ * assembled. What it buys is a build that scans nothing of another package's
51
+ * source tree. 🔴 What makes a consumer smaller is the AREAS, and only the
52
+ * package could build them: Tailwind cannot aim a `@source` at anything finer
53
+ * than a directory, but `@tailwindcss/oxide` can be handed a FILE LIST, which is
54
+ * what `scripts/generateAreaStyles.ts` does. Measured on the fixture consumer
55
+ * `demo/fixtures/lean-app`, the area route emits 30% less CSS than the union, and
56
+ * `src/fixtureAppCss.spec.ts` is the ratchet that stops it climbing back. The
57
+ * area map, and a sentence on what each area is for, is `scripts/styleAreas.ts`.
48
58
  *
49
59
  * 🔴 This header is in BOTH of the first two files, because one is generated from
50
60
  * the other and a test holds them byte-identical apart from the `@source` line.
51
61
  * Always edit `src/styles.css`; never `src/styles-static.css`, which
52
62
  * `scripts/generateStaticStyles.ts` rewrites on every `bun run build` and
53
63
  * `src/stylesStaticMatches.spec.ts` fails on when it is stale. Editing this file
54
- * invalidates `src/styles-utilities.css` too — it is compiled against the static
55
- * variant, `scripts/generateUtilityStyles.ts` rewrites it in the same build step,
56
- * and `src/stylesUtilitiesMatches.spec.ts` is what fails when it is stale.
64
+ * invalidates `src/styles-utilities.css` and every `src/styles-areas/*.css` too —
65
+ * all of them are compiled against the static variant, `bun run styles` rewrites
66
+ * them in the same build step, and `src/stylesUtilitiesMatches.spec.ts` /
67
+ * `src/stylesAreas.spec.ts` are what fail when they are stale.
57
68
  */
58
69
 
59
70
  /* Animation utilities (animate-in/out, fade/zoom/slide-in-from-*) consumed by the
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `src/styles-areas/*.css` is a current, COMPLETE, non-overlapping split of
3
+ * `src/styles-utilities.css` (2026-09-15).
4
+ *
5
+ * ## What this exists to prevent
6
+ *
7
+ * The per-area split is the first change this package has made that can make a
8
+ * consuming app SMALLER, and every way it can go wrong produces a smaller
9
+ * stylesheet — which is the thing five apps asked for. A missing area, a stale
10
+ * artifact, a collapsed scan and a mis-assigned module all look like success from
11
+ * the outside. So nothing here reads a byte count in the direction people read it.
12
+ *
13
+ * Four properties, and the split is worthless without all four:
14
+ *
15
+ * 1. CURRENT — what is on disk is what `src/react/**` produces right now. The
16
+ * artifacts replace a scan the app used to run, and a scan was self-healing
17
+ * where a checked-in file is not.
18
+ * 2. COVERING — every module under `src/react` is in some area. A module in no
19
+ * area is in no stylesheet, so it renders unstyled in every app that adopted
20
+ * this, while still typechecking and still being present in the union that
21
+ * apps on the old route import.
22
+ * 3. COMPLETE — core plus every area emits exactly what the union emits, rule
23
+ * for rule. This is the assertion that says the split lost nothing.
24
+ * 4. SELF-CONTAINED — an area's stylesheet covers everything its own components
25
+ * import, which is a property of how an area is DEFINED (the import closure
26
+ * of its roots, `scripts/styleAreas.ts`) and is asserted here so a future
27
+ * hand-written shortcut cannot quietly take it away.
28
+ *
29
+ * `scripts/styleAreas.ts` owns the WHY and the map; `scripts/generateAreaStyles.ts`
30
+ * is the compiler; this file is the WHETHER.
31
+ *
32
+ * 🔴 `Bun.file`/`Bun.Glob` for every read, never node:fs — this package's test
33
+ * preload virtualizes node:fs, so a `readFileSync` scan here reads MOCK data and
34
+ * passes against files it never opened. Same trap the sibling specs name; see the
35
+ * header of `src/publishShape.spec.ts`.
36
+ */
37
+ import { describe, expect, it } from 'bun:test';
38
+ import pkg from '../package.json';
39
+ import { flatten, lostRules, referenced } from '../scripts/cssRules';
40
+ import { REPO_ROOT } from '../scripts/generateStaticStyles';
41
+ import {
42
+ AREAS,
43
+ CORE,
44
+ MIN_CORE_CANDIDATES,
45
+ areaFile,
46
+ areaSubpath,
47
+ readAreaStyles,
48
+ strayAreaFiles,
49
+ } from '../scripts/generateAreaStyles';
50
+ import { compileStylesheet } from '../scripts/generateUtilityStyles';
51
+ import {
52
+ allModules,
53
+ areaClosures,
54
+ candidatesIn,
55
+ coverageFault,
56
+ needsGraphFault,
57
+ nonModuleFiles,
58
+ transitiveNeeds,
59
+ } from '../scripts/styleAreas';
60
+
61
+ const areas = await readAreaStyles();
62
+ const { closures } = await areaClosures();
63
+
64
+ /** The command every staleness failure below is fixed by, as a line the owner can paste. */
65
+ const REGENERATE = `cd ${REPO_ROOT} && bun run styles:areas`;
66
+
67
+ /** A consumer that imports EVERY area — the only route comparable to the union. */
68
+ const all = AREAS.map((a) => `@import "./styles-areas/${a.name}.css";`).join('\n');
69
+ const union = (
70
+ await compileStylesheet('@import "tailwindcss" source(none);\n@import "./styles-static.css";\n@import "./styles-utilities.css";\n@import "./theme.css";\n')
71
+ ).build([]);
72
+ const split = (
73
+ await compileStylesheet(`@import "tailwindcss" source(none);\n@import "./styles-static.css";\n${all}\n@import "./theme.css";\n`)
74
+ ).build([]);
75
+ const before = flatten(union);
76
+ const after = flatten(split);
77
+ /** Hoisted: the loop below asks this per declaration, over 130 KB of CSS. */
78
+ const readByUnion = referenced(union);
79
+
80
+ /**
81
+ * Rules the union emits ONLY because its `@source` scan reads files that are not
82
+ * modules — this package's own specs, its hand-written `.css`, its JSON corpora.
83
+ * See `nonModuleFiles()` for why they are correctly dropped; this is what proves
84
+ * they are the only thing dropped.
85
+ */
86
+ const specOnly = (async () => {
87
+ const fromOthers = await candidatesIn(nonModuleFiles());
88
+ const fromModules = await candidatesIn(allModules());
89
+ const only = [...fromOthers].filter((c) => !fromModules.has(c)).sort();
90
+ const compiled = await compileStylesheet('@import "tailwindcss" source(none);\n@import "./styles-static.css";\n@import "./theme.css";\n');
91
+ return { keys: new Set(flatten(compiled.build(only)).keys()), count: only.length };
92
+ })();
93
+
94
+ describe('the per-area stylesheets are generated and gated', () => {
95
+ it('has an area map worth checking at all', () => {
96
+ // Vacuity guard for everything below: an empty AREAS, or one where `core` is
97
+ // not first, makes every other assertion pass while proving nothing.
98
+ expect(AREAS.length).toBeGreaterThan(10);
99
+ expect(AREAS[0]?.name).toBe(CORE);
100
+ expect(areas.length).toBe(AREAS.length);
101
+ });
102
+
103
+ it('every area says what it is for, so a consumer can decide to decline it', () => {
104
+ // An area nobody can explain is an area nobody can leave out, and the whole
105
+ // mechanism is consumers leaving areas out.
106
+ const unexplained = AREAS.filter((a) => a.why.trim().length < 20).map((a) => a.name);
107
+ expect(unexplained, `these areas have no usable \`why\`:\n ${unexplained.join('\n ')}`).toEqual([]);
108
+ });
109
+
110
+ it('is checked in and byte-identical to what the generator produces', () => {
111
+ const stale = areas.filter((a) => a.onDisk !== a.css).map((a) => `${areaFile(a.name)} (${a.onDisk === null ? 'missing' : 'stale'})`);
112
+ expect(
113
+ stale,
114
+ 'these area stylesheets are not what src/react currently produces, so every app that\n' +
115
+ 'imports one is missing the difference — a component that renders unstyled in\n' +
116
+ `production and nowhere else. Run:\n ${REGENERATE}\n ` +
117
+ stale.join('\n '),
118
+ ).toEqual([]);
119
+ });
120
+
121
+ it('leaves no stylesheet behind for an area that no longer exists', () => {
122
+ // A renamed area leaves its old file on disk, where it keeps being published and
123
+ // keeps working — until the day it is stale and nothing regenerates it.
124
+ expect(strayAreaFiles(), `delete these; no area names them:\n ${strayAreaFiles().join('\n ')}`).toEqual([]);
125
+ });
126
+
127
+ it('was compiled from a scan that did not collapse', () => {
128
+ const core = areas.find((a) => a.name === CORE);
129
+ expect(core?.candidates.length ?? 0).toBeGreaterThanOrEqual(MIN_CORE_CANDIDATES);
130
+ });
131
+
132
+ it('covers every module under src/react', () => {
133
+ // Property 2. `coverageFault` is what the generator refuses on; this is the
134
+ // assertion that fails in milliseconds naming the orphan, rather than at the
135
+ // next app's build.
136
+ expect(coverageFault(closures)).toBeNull();
137
+ // And the walk itself is real — a glob that returns nothing would make the
138
+ // check above pass vacuously.
139
+ expect(allModules().length).toBeGreaterThan(300);
140
+ expect(nonModuleFiles().length).toBeGreaterThan(20);
141
+ });
142
+
143
+ it('has no dangling or circular `needs`', () => {
144
+ // A cycle is the one failure that corrupts rather than bloats: two areas each
145
+ // subtracting the other means a shared candidate is emitted by NEITHER.
146
+ expect(needsGraphFault()).toBeNull();
147
+ });
148
+
149
+ it('is self-contained: an area covers everything its own modules import', () => {
150
+ // Property 4. True by construction today (an area IS an import closure), and
151
+ // asserted anyway so a future hand-listed area cannot silently take it away.
152
+ const holes: string[] = [];
153
+ for (const area of AREAS) {
154
+ const mine = closures.get(area.name) as Set<string>;
155
+ const allowed = new Set([...mine, ...(closures.get(CORE) as Set<string>)]);
156
+ for (const need of transitiveNeeds(area.name)) for (const f of closures.get(need) ?? []) allowed.add(f);
157
+ for (const file of mine) if (!allowed.has(file)) holes.push(`${area.name} → ${file}`);
158
+ }
159
+ expect(holes, `these areas reach a module they do not ship the CSS for:\n ${holes.join('\n ')}`).toEqual([]);
160
+ });
161
+
162
+ it('has compiled two real consumer stylesheets to compare', () => {
163
+ // Guards the completeness assertion below: two empty strings agree about
164
+ // everything, and both of these are built with no candidates at all, so a
165
+ // broken `@import` would produce exactly that.
166
+ expect(before.size).toBeGreaterThan(500);
167
+ expect(after.size).toBeGreaterThan(500);
168
+ });
169
+
170
+ it('emits, across all areas, every rule the union emits', async () => {
171
+ // Property 3 — the core proof, and the reason this file does not compare
172
+ // sizes. A missing key is a selector that vanished from the design system; a
173
+ // missing declaration is a property that stopped applying. Same normalisation
174
+ // the sibling spec documents: `@theme inline` aliases nothing reads are
175
+ // bookkeeping, not rules.
176
+ const { rules, declarations } = lostRules(before, after, (prop) => !readByUnion.has(prop));
177
+ // Everything the split drops must be a test fixture's class and nothing else.
178
+ // Bounded as well as explained: an amnesty that can absorb 300 rules is not an
179
+ // explanation, it is a hole.
180
+ const { keys, count } = await specOnly;
181
+ expect(count, 'nothing but modules under src/react — the allowance below measures nothing').toBeGreaterThan(0);
182
+ const unexplained = rules.filter((key) => !keys.has(key));
183
+ // A bound as well as an explanation: an allowance that can absorb 300 rules is
184
+ // not an explanation, it is a hole. The drop is the handful of classes only a
185
+ // `*.spec.tsx` fixture uses, and it is asserted to stay that size.
186
+ expect(rules.length, `${rules.length} rules dropped — far more than this package's test fixtures use`).toBeLessThan(60);
187
+ expect(
188
+ unexplained,
189
+ 'the per-area split emits FEWER rules than `styles-utilities.css` does, and these\n' +
190
+ 'are not explained by the non-module files the union scan sweeps up. Every selector\n' +
191
+ 'listed renders unstyled even in an app that imported EVERY area, which means the\n' +
192
+ `split lost them, not that an app under-declared. Usually staleness:\n ${REGENERATE}\n ` +
193
+ unexplained.slice(0, 20).join('\n '),
194
+ ).toEqual([]);
195
+ expect(
196
+ declarations,
197
+ 'the split emits these selectors but not these declarations, so the affected\n' +
198
+ `components render PARTLY styled. Usually staleness:\n ${REGENERATE}\n ` +
199
+ declarations.slice(0, 20).join('\n '),
200
+ ).toEqual([]);
201
+ });
202
+
203
+ it('is worth having: core is a fraction of the union, not the union renamed', async () => {
204
+ // The negative control for the whole exercise. If `core` grew until it were the
205
+ // union again — one careless root away, since a root's closure is transitive —
206
+ // every assertion above would still pass and no consumer would save a byte.
207
+ const core = areas.find((a) => a.name === CORE) as (typeof areas)[number];
208
+ const utilities = await Bun.file(`${REPO_ROOT}/src/styles-utilities.css`).text();
209
+ expect(
210
+ core.css.length,
211
+ `core is ${core.css.length} B against a ${utilities.length} B union — an area map where the\n` +
212
+ 'primitives ARE the design system buys nothing. Move whatever grew it into an area.',
213
+ ).toBeLessThan(utilities.length * 0.75);
214
+ });
215
+
216
+ it('exports every area as a subpath that resolves into a directory `files` ships', () => {
217
+ const exportsMap = (pkg as { exports: Record<string, unknown> }).exports;
218
+ const missing = AREAS.filter((a) => exportsMap[areaSubpath(a.name)] !== `./${areaFile(a.name)}`).map((a) => a.name);
219
+ expect(
220
+ missing,
221
+ 'these areas are generated but not exported, so `@import "cursedbelt/styles-areas/<x>.css"`\n' +
222
+ `in an app throws ERR_PACKAGE_PATH_NOT_EXPORTED:\n ${missing
223
+ .map((n) => `"${areaSubpath(n)}": "./${areaFile(n)}"`)
224
+ .join('\n ')}`,
225
+ ).toEqual([]);
226
+ // And nothing exported that no area produces — a renamed area leaves the old
227
+ // key behind, pointing at a file the next build deletes.
228
+ const known = new Set(AREAS.map((a) => areaSubpath(a.name)));
229
+ const dead = Object.keys(exportsMap).filter((k) => k.startsWith('./styles-areas/') && !known.has(k));
230
+ expect(dead, `these export subpaths name no area:\n ${dead.join('\n ')}`).toEqual([]);
231
+ expect((pkg as { files: string[] }).files).toContain('src');
232
+ });
233
+
234
+ it('resolves as `cursedbelt/styles-areas/core.css` through the real resolver', () => {
235
+ // The assertions above read the manifest; this one RESOLVES the specifier the
236
+ // way a consumer's bundler does. An export entry pointing at a path that does
237
+ // not exist satisfies every string comparison in this file and fails here.
238
+ expect(Bun.resolveSync(`${pkg.name}/styles-areas/${CORE}.css`, REPO_ROOT)).toBe(`${REPO_ROOT}/${areaFile(CORE)}`);
239
+ });
240
+
241
+ it('is regenerated by the build, so a stale area cannot be published', () => {
242
+ // `prepublishOnly` → `verify` → `build`, so the tarball's copies are always the
243
+ // ones this repo's current src/react produces.
244
+ expect((pkg as { scripts: Record<string, string> }).scripts.build).toContain('scripts/generateAreaStyles.ts');
245
+ expect((pkg as { scripts: Record<string, string> }).scripts.styles).toContain('styles:areas');
246
+ });
247
+ });
@@ -45,6 +45,7 @@
45
45
  */
46
46
  import { describe, expect, it } from 'bun:test';
47
47
  import pkg from '../package.json';
48
+ import { CUSTOM_PROPERTY, declared, dropFallbacks, flatten, referenced } from '../scripts/cssRules';
48
49
  import {
49
50
  CONSUMER_PRECOMPILED,
50
51
  CONSUMER_SCANNING,
@@ -60,83 +61,11 @@ const { expected, onDisk, candidates } = await readUtilityStylesheet();
60
61
  /** The command every staleness failure below is fixed by, as a line the owner can paste. */
61
62
  const REGENERATE = `cd ${REPO_ROOT} && bun run styles:utilities`;
62
63
 
63
- /**
64
- * Flatten a stylesheet into `at-rule ‖ … ‖ selector` → the set of declarations in
65
- * that block, merging blocks that repeat (both routes emit `@keyframes spin`
66
- * twice; a consumer's browser does not care, and neither should a comparison).
67
- *
68
- * A hand-rolled walk rather than a CSS parser because the only input is Tailwind's
69
- * own output: well-formed, and the one construct that could confuse brace counting
70
- * — a `{` inside a string or a comment — is skipped explicitly.
71
- */
72
- const flatten = (css: string): Map<string, Set<string>> => {
73
- const out = new Map<string, Set<string>>();
74
- const stack: string[] = [];
75
- const open: string[][] = [[]];
76
- let buf = '';
77
- const push = (raw: string): void => {
78
- const decl = raw.trim();
79
- if (decl) open[open.length - 1]?.push(decl);
80
- };
81
- for (let i = 0; i < css.length; i++) {
82
- const ch = css[i];
83
- if (ch === '/' && css[i + 1] === '*') {
84
- i = css.indexOf('*/', i) + 1;
85
- continue;
86
- }
87
- if (ch === '"' || ch === "'") {
88
- const end = css.indexOf(ch, i + 1);
89
- buf += css.slice(i, end + 1);
90
- i = end;
91
- continue;
92
- }
93
- if (ch === '{') {
94
- stack.push(buf.trim().replace(/\s+/g, ' '));
95
- buf = '';
96
- open.push([]);
97
- continue;
98
- }
99
- if (ch === '}') {
100
- push(buf);
101
- buf = '';
102
- const key = stack.join(' ‖ ');
103
- const set = out.get(key) ?? new Set<string>();
104
- for (const decl of open.pop() ?? []) set.add(decl);
105
- out.set(key, set);
106
- stack.pop();
107
- continue;
108
- }
109
- // At depth 0 a `;` ends a statement at-rule (`@layer a, b;`, `@import …`),
110
- // which carries no declarations and must not stick to the next selector.
111
- if (ch === ';') {
112
- if (stack.length) push(buf);
113
- buf = '';
114
- continue;
115
- }
116
- buf += ch;
117
- }
118
- return out;
119
- };
120
-
121
- /** `var(--x, <anything>)` → `var(--x)`, so a fallback reads as the same declaration. */
122
- const dropFallbacks = (decl: string): string => decl.replace(/var\((\s*--[\w-]+)[^)]*\)/g, 'var($1)');
123
-
124
- const CUSTOM_PROPERTY = /^(--[\w-]+)\s*:/;
125
-
126
- /** Every custom property a stylesheet actually reads through `var()`. */
127
- const referenced = (css: string): Set<string> => {
128
- const out = new Set<string>();
129
- for (const m of css.matchAll(/var\(\s*(--[\w-]+)/g)) out.add(m[1] as string);
130
- return out;
131
- };
132
-
133
- /** Every custom property a stylesheet declares, including `@property` registrations. */
134
- const declared = (css: string): Set<string> => {
135
- const out = new Set<string>();
136
- for (const m of css.matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:/g)) out.add(m[1] as string);
137
- for (const m of css.matchAll(/@property\s+(--[\w-]+)/g)) out.add(m[1] as string);
138
- return out;
139
- };
64
+ // 🔴 The flattener, the `var()` fallback normalisation and the two custom-property
65
+ // walks used to live here. They moved to `scripts/cssRules.ts` when
66
+ // `src/stylesAreas.spec.ts` needed the same comparison for the per-area split — one
67
+ // copy, because a second flattener is a second thing that can drift from the
68
+ // assertions that rely on it. The reasoning for each is in that file's header.
140
69
 
141
70
  // The two routes, compiled from the SAME candidate list (the classes cursedbelt's
142
71
  // components use), so neither side is credited with utilities the other was never
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 🔴 `bun test` runs every file in ONE process unless it is told not to. This repo has 269
3
+ * of them, and that one process was the longest stage in the gate.
4
+ *
5
+ * ── What it cost, measured 2026-09-17 ───────────────────────────────────────
6
+ * `NODE_ENV=development bun test src` — 2,689 tests across 269 files — took **33.8s at
7
+ * 104% CPU** on a fourteen-core M3 Max. One core. The same suite:
8
+ *
9
+ * | workers | wall | pass / fail |
10
+ * |---|---|---|
11
+ * | 1 (the default) | 33.8s | 2689 / 0 |
12
+ * | `--parallel=4` | 17.0s | 2689 / 0 |
13
+ * | **`--parallel=6`** | **14.6s** | 2689 / 0 |
14
+ * | `--parallel` (14) | 11.8s | 2689 / 0 |
15
+ *
16
+ * Six, not fourteen, and the reason is the one `scripts/verify.ts` gives for its own job
17
+ * cap: `test` runs BESIDE `e2e` in the graph, and e2e is seven Playwright workers driving
18
+ * seven browsers. Fourteen more processes on top converts parallelism into thrash, and
19
+ * browser specs degrade worse than anything else under contention. The gain from 6 → 14 is
20
+ * 2.8s; the cost is paid by the stage that is already the longest.
21
+ *
22
+ * ── 🔴 This is a STRONGER gate, not a smaller one ───────────────────────────
23
+ * `--parallel` implies `--isolate`: each file gets a fresh global and module registry.
24
+ * Plain `bun test` shares one registry across all 269 files, so a spec that only passes
25
+ * because another file left state behind passes there and fails here. All 2,689 tests pass
26
+ * either way — which is the evidence that the isolation was not load-bearing, and it is
27
+ * worth knowing rather than assuming.
28
+ *
29
+ * The scratch directory was already safe for this: `scripts/testTmpRoot.ts` gives every
30
+ * PROCESS its own `$TMPDIR` under `$FORGE_STATE/test-scratch.noindex/tmp/<pid>`, written
31
+ * for exactly this reason before anything ran in parallel.
32
+ *
33
+ * ── Why a source assertion ──────────────────────────────────────────────────
34
+ * Same shape as `src/buildIsStaged.spec.ts`: what regresses is the SCRIPT — somebody
35
+ * simplifying a flag away, or copying the plain `bun test src` out of another repo — and a
36
+ * gate that silently goes back to 33.8s single-threaded looks exactly like a gate that is
37
+ * merely slow. Reading the string costs a millisecond; re-measuring it costs 34 seconds.
38
+ */
39
+ import { describe, expect, test } from 'bun:test';
40
+ import { readFileSync } from 'node:fs';
41
+
42
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
43
+ scripts: Record<string, string>;
44
+ };
45
+
46
+ describe('the unit suite runs on more than one core', () => {
47
+ const script = pkg.scripts.test;
48
+
49
+ test('`bun run test` still names a parallel worker count', () => {
50
+ expect(script, 'package.json has no `test` script').toBeString();
51
+ expect(
52
+ /--parallel(=\d+)?\b/.test(script),
53
+ `\`test\` is "${script}" — with no \`--parallel\`, bun runs all 269 files in one ` +
54
+ 'process on one core, which measured 33.8s against 14.6s. Put the flag back, or ' +
55
+ 'say here why the suite may no longer be isolated per file.',
56
+ ).toBe(true);
57
+ });
58
+
59
+ test('the worker count stays inside the budget the verify graph assumes', () => {
60
+ // `scripts/verify.ts` runs up to `jobLimit()` stages at once and `e2e` is seven
61
+ // browsers. A bare `--parallel` means one worker per core, which is that budget
62
+ // ignored — so the number is written down, and it is written down here as well as in
63
+ // package.json because the two have to agree about why.
64
+ const asked = /--parallel=(\d+)\b/.exec(script);
65
+ expect(
66
+ asked,
67
+ `\`test\` is "${script}" — a bare \`--parallel\` takes one worker per core (14 on ` +
68
+ 'this Mac) while `e2e` is already seven browsers beside it. Name the count.',
69
+ ).not.toBeNull();
70
+ const workers = Number(asked?.[1]);
71
+ expect(workers).toBeGreaterThanOrEqual(2);
72
+ expect(
73
+ workers,
74
+ 'more workers than a third of the cores is the over-subscription `scripts/verify.ts` ' +
75
+ 'caps its own stage count to avoid; measured, 6 → 14 workers buys 2.8s and is paid ' +
76
+ 'for by the browser stage running beside it.',
77
+ ).toBeLessThanOrEqual(8);
78
+ });
79
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 🔴 Four `tsc` projects run AT ONCE in `scripts/verify.ts`. Two of them writing one
3
+ * `.tsbuildinfo` is a corrupt cache, and a corrupt cache is a typecheck that skips files.
4
+ *
5
+ * ## Why this file exists
6
+ *
7
+ * Measured 2026-09-17, on a gate that was taking 7 minutes: `src` is type-checked FOUR
8
+ * times per run — `typecheck:root`, `typecheck:specs`, `build`, and again through
9
+ * `demo/tsconfig.json`'s `paths` — and not one of those runs remembered anything about the
10
+ * last one. 764 files, re-checked from cold, four times over, on a commit that changed one
11
+ * component. `typecheck:root` alone: 6.4s cold, 2.0s after a real edit to the largest
12
+ * component in the repo, 1.5s with nothing changed.
13
+ *
14
+ * Turning `incremental` on is the obvious fix and it brings its own trap with it, which is
15
+ * what this spec holds. `tsconfig.specs.json`, `tsconfig.scripts.json` and
16
+ * `tsconfig.build.json` all `extends` `tsconfig.json` — so a `tsBuildInfoFile` named there
17
+ * and not overridden is INHERITED, and the next project added to this repo gets the
18
+ * collision for free, silently, with a green gate on either side of it.
19
+ *
20
+ * The failure would not look like a failure. tsc would read a cache another process wrote
21
+ * mid-write, decide some files were current, and check fewer of them than it reported. That
22
+ * is the false-green shape this repo keeps paying for, so it gets a check rather than a
23
+ * paragraph in a tsconfig.
24
+ *
25
+ * 🔴 The configs are read through `tsc --showConfig`, never parsed here. That is the only
26
+ * reader that resolves `extends` the way the compiler does — and `extends` is the entire
27
+ * point: a project that forgets to override the parent's path is exactly the bug. It costs
28
+ * ~90ms per project because `--showConfig` does no type checking at all.
29
+ */
30
+ import { describe, expect, it } from 'bun:test';
31
+ import { spawnSync } from 'node:child_process';
32
+ import { dirname, relative, resolve } from 'node:path';
33
+
34
+ const ROOT = resolve(import.meta.dir, '..');
35
+
36
+ /** Every tsconfig this repo owns. Globbed, so a new one is covered the day it lands. */
37
+ function projectPaths(): string[] {
38
+ const glob = new Bun.Glob('**/tsconfig*.json');
39
+ return [...glob.scanSync({ cwd: ROOT, absolute: true })]
40
+ .filter((p) => !p.includes(`${ROOT}/node_modules`) && !p.includes('/dist'))
41
+ .sort();
42
+ }
43
+
44
+ interface Resolved {
45
+ /** Repo-relative, for a failure message somebody can act on. */
46
+ name: string;
47
+ incremental: boolean;
48
+ /** Absolute, because two projects in different directories can name the same file. */
49
+ buildInfo: string | null;
50
+ emits: boolean;
51
+ }
52
+
53
+ function resolveProject(path: string): Resolved {
54
+ const r = spawnSync(process.execPath, ['x', 'tsc', '--showConfig', '-p', path], {
55
+ cwd: ROOT,
56
+ encoding: 'utf8',
57
+ });
58
+ expect(r.status, `tsc --showConfig failed for ${path}:\n${r.stderr}`).toBe(0);
59
+ const options = (JSON.parse(r.stdout) as { compilerOptions?: Record<string, unknown> }).compilerOptions ?? {};
60
+ const info = options.tsBuildInfoFile;
61
+ return {
62
+ name: relative(ROOT, path),
63
+ incremental: options.incremental === true,
64
+ buildInfo: typeof info === 'string' ? resolve(dirname(path), info) : null,
65
+ emits: options.noEmit !== true,
66
+ };
67
+ }
68
+
69
+ const PROJECTS = projectPaths().map(resolveProject);
70
+
71
+ describe('the typecheck caches', () => {
72
+ it('finds every tsconfig in the repo', () => {
73
+ // A glob that has quietly stopped matching would make every assertion below vacuous.
74
+ expect(PROJECTS.length, 'no tsconfig found — the glob is wrong, not the repo').toBeGreaterThanOrEqual(5);
75
+ });
76
+
77
+ it('🔴 never lets two projects write the same .tsbuildinfo', () => {
78
+ const byFile = new Map<string, string[]>();
79
+ for (const p of PROJECTS) {
80
+ if (!p.incremental || p.buildInfo === null) continue;
81
+ byFile.set(p.buildInfo, [...(byFile.get(p.buildInfo) ?? []), p.name]);
82
+ }
83
+ const shared = [...byFile.entries()].filter(([, names]) => names.length > 1);
84
+ expect(
85
+ shared.map(([file, names]) => `${relative(ROOT, file)} ← ${names.join(', ')}`),
86
+ 'these projects share one incremental cache. `scripts/verify.ts` runs them ' +
87
+ 'concurrently, so both processes write the same file and whatever survives is what ' +
88
+ 'the NEXT run trusts. Give each project its own `tsBuildInfoFile` — an `extends` ' +
89
+ 'inherits the parent\'s.',
90
+ ).toEqual([]);
91
+ });
92
+
93
+ it('🔴 keeps `incremental` off wherever tsc emits into a staged directory', () => {
94
+ // `bun run build` deletes `dist.next`, compiles into it, and renames it over `dist`. A
95
+ // cache that survives that describes outputs which are no longer where it says they are
96
+ // — and tsc may then SKIP emitting them. `src/buildIsStaged.spec.ts` guards the same
97
+ // dist against the other way of emptying it.
98
+ const emittingAndCached = PROJECTS.filter((p) => p.emits && p.incremental).map((p) => p.name);
99
+ expect(
100
+ emittingAndCached,
101
+ 'an emitting project must not be incremental here: the build stages into `dist.next` ' +
102
+ 'and swaps, so the cache and the output directory disagree by construction, and the ' +
103
+ 'symptom is a published dist with files missing.',
104
+ ).toEqual([]);
105
+ });
106
+
107
+ it('🔴 every project that is NOT emitting is cached', () => {
108
+ // The other direction, and the reason this one is a test: the gate got faster by
109
+ // remembering, and the tempting way to fix any tsc oddity is to delete `incremental`
110
+ // from the project that is misbehaving. That would put the four-times-over cold check
111
+ // back with nothing to notice.
112
+ const uncached = PROJECTS.filter((p) => !p.emits && !p.incremental).map((p) => p.name);
113
+ expect(
114
+ uncached,
115
+ 'these type-only projects re-check every file from cold on every gate run. Give them ' +
116
+ '`incremental: true` and their own `tsBuildInfoFile` (see `tsconfig.json`).',
117
+ ).toEqual([]);
118
+ });
119
+
120
+ it('keeps every cache inside the one gitignored directory', () => {
121
+ // One place to delete when a cache ever does need clearing, and one line in .gitignore
122
+ // rather than a pattern that has to anticipate the next file name.
123
+ const stray = PROJECTS.filter((p) => p.buildInfo !== null && !p.buildInfo.startsWith(`${ROOT}/.tscache/`)).map(
124
+ (p) => `${p.name} → ${relative(ROOT, p.buildInfo as string)}`,
125
+ );
126
+ expect(stray, 'every `tsBuildInfoFile` belongs under `.tscache/`, which .gitignore covers').toEqual([]);
127
+ });
128
+ });
@@ -39,6 +39,12 @@ const MUST_RUN = [
39
39
  // checking code that lives elsewhere would be the violation, not the fix. Removed in the
40
40
  // same commit as the stage in scripts/verify.ts, which is what this comment is for.
41
41
  'typecheck:specs',
42
+ // 🔴 Not part of the original chain — added 2026-09-16 (task 172), and pinned here the
43
+ // same way, because it is the only thing that compiles `scripts/`. `cursedbelt/guardrails`
44
+ // is an EXPORT resolving into that tree, so dropping this stage puts four apps back to
45
+ // inheriting type errors from a file nothing here checks. "Adding a stage is free" cuts
46
+ // both ways: once added, removing it argues with this list.
47
+ 'typecheck:scripts',
42
48
  'demo:check',
43
49
  'styles',
44
50
  'build',