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.
- package/dist/react/components/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- 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
|
-
* ──
|
|
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
|
|
16
|
+
* A CSS `@import` takes the whole file, so the split is more subpaths:
|
|
17
17
|
*
|
|
18
|
-
* cursedbelt/styles.css
|
|
19
|
-
* cursedbelt/styles-static.css
|
|
20
|
-
* cursedbelt/styles-utilities.css
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
44
|
-
* rules within 54 bytes
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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`
|
|
55
|
-
*
|
|
56
|
-
* and `src/stylesUtilitiesMatches.spec.ts`
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
});
|
package/src/verifyGraph.spec.ts
CHANGED
|
@@ -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',
|