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/scripts/verify.ts
CHANGED
|
@@ -76,6 +76,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
76
76
|
{ script: "paths" },
|
|
77
77
|
{ script: "typecheck:root" },
|
|
78
78
|
{ script: "typecheck:specs" },
|
|
79
|
+
// 🔴 Added 2026-09-16 (task 172). `tsconfig.json` includes `src` only, so the eleven
|
|
80
|
+
// files under `scripts/` were compiled by nothing — including `guardrailsEnforce.ts`,
|
|
81
|
+
// which package.json EXPORTS as `cursedbelt/guardrails`. Four apps were typechecking a
|
|
82
|
+
// file this repo's own gate had never looked at, and inheriting its errors.
|
|
83
|
+
{ script: "typecheck:scripts" },
|
|
79
84
|
{ script: "demo:check" },
|
|
80
85
|
{ script: "styles" },
|
|
81
86
|
{
|
|
@@ -70,9 +70,18 @@ const REPO = fileURLToPath(new URL('..', import.meta.url));
|
|
|
70
70
|
* task above is about; a `await import('sharp')` inside the function that needs it is
|
|
71
71
|
* exactly the right shape and stays legal here.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
73
|
+
* The externals are read out of the bundle's import statements textually.
|
|
74
|
+
*
|
|
75
|
+
* ⛔ **Corrected 2026-09-15 (task 149).** This paragraph used to admit the scan as "a floor,
|
|
76
|
+
* not a ceiling" because it only matched an import bun emitted on ONE line — and the
|
|
77
|
+
* admission was worth more than it looked. Bundling `./react` and diffing the result against
|
|
78
|
+
* the manifest showed the old pattern silently missing `cmdk` and `@tanstack/react-table`,
|
|
79
|
+
* both of which bun emits as a multi-line `import {\n … \n} from "pkg";`. Nothing was red at
|
|
80
|
+
* the time, because both are hard `dependencies` rather than optional peers — but the
|
|
81
|
+
* MEASUREMENT half of task 149 ran off this same scan, and a dependency graph with two
|
|
82
|
+
* packages missing from it is the kind of thing a decision gets made on. So the matcher is
|
|
83
|
+
* anchored on `from "…"` wherever it appears now, plus the bare `import "…";` form, and the
|
|
84
|
+
* limitation is gone rather than documented.
|
|
76
85
|
*/
|
|
77
86
|
const NO_STATIC_OPTIONAL_PEERS = [
|
|
78
87
|
{
|
|
@@ -92,8 +101,14 @@ const OPTIONAL_PEERS = new Set(
|
|
|
92
101
|
.map(([name]) => name),
|
|
93
102
|
);
|
|
94
103
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
104
|
+
/**
|
|
105
|
+
* `import x from "pkg"` · `export … from "pkg"` · the side-effect-only `import "pkg";`.
|
|
106
|
+
*
|
|
107
|
+
* 🔴 Anchored on the `from` clause, NOT on a line starting with `import`, so a multi-line
|
|
108
|
+
* named import still counts (see the correction above). A DYNAMIC `import("pkg")` matches
|
|
109
|
+
* neither branch and must not: that is the lazy shape this whole file exists to permit.
|
|
110
|
+
*/
|
|
111
|
+
const STATIC_SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
|
|
97
112
|
|
|
98
113
|
/**
|
|
99
114
|
* 🔴 An OUTDIR, not stdout, and this repo is why (2026-09-15).
|
|
@@ -107,7 +122,7 @@ const STATIC_SPECIFIER = /^(?:import|export)\s[^\n]*?["']([^"'\n]+)["'];?$|^impo
|
|
|
107
122
|
* `.js` it emits — a stylesheet cannot import a package, so the CSS output is
|
|
108
123
|
* irrelevant to the question and only the JS is read.
|
|
109
124
|
*/
|
|
110
|
-
const
|
|
125
|
+
const staticExternalsOf = (entry: string): Set<string> => {
|
|
111
126
|
const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-barrel-scan/${entry.replace(/[^a-z0-9]+/gi, '-')}`;
|
|
112
127
|
const build = Bun.spawnSync(
|
|
113
128
|
['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir],
|
|
@@ -123,13 +138,64 @@ const staticOptionalPeersOf = (entry: string): string[] => {
|
|
|
123
138
|
const found = new Set<string>();
|
|
124
139
|
for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
|
|
125
140
|
const specifier = match[1] ?? match[2];
|
|
126
|
-
if (specifier === undefined || specifier.startsWith('.'))
|
|
141
|
+
if (specifier === undefined || specifier.startsWith('.') || specifier.startsWith('bun:')) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
127
144
|
// `@scope/name/deep` → `@scope/name`, `pkg/deep` → `pkg`.
|
|
128
145
|
const parts = specifier.split('/');
|
|
129
146
|
const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
130
|
-
if (name !== undefined
|
|
147
|
+
if (name !== undefined) found.add(name);
|
|
131
148
|
}
|
|
132
|
-
return
|
|
149
|
+
return found;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const staticOptionalPeersOf = (entry: string): string[] =>
|
|
153
|
+
[...staticExternalsOf(entry)].filter((name) => OPTIONAL_PEERS.has(name)).sort();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 🔴 The INVERSE of everything above, and the half task 149 had to add (2026-09-15).
|
|
157
|
+
*
|
|
158
|
+
* The rule the optional-peer check enforces — *the barrel must not drag a package a
|
|
159
|
+
* consumer may never call* — has an exact mirror that nothing was enforcing: **a hard
|
|
160
|
+
* `dependency` the barrel never touches is a package EVERY consumer installs and no
|
|
161
|
+
* consumer's `cursedbelt/react` import can reach.** Both are the same defect measured
|
|
162
|
+
* from opposite ends, and the second one is the expensive one, because it is invisible:
|
|
163
|
+
* the manifest is green, the import graph is clean, and the app just pays.
|
|
164
|
+
*
|
|
165
|
+
* Measured into an empty directory at 3.0.2, before this check existed: eight of the
|
|
166
|
+
* thirty-five runtime `dependencies` were in exactly that state — `emoji-mart` and its
|
|
167
|
+
* two siblings (already loaded through `await import()` in `emoji/EmojiPicker.tsx` and
|
|
168
|
+
* `emoji/emojiSearch.ts`), `@git-diff-view/react` + `diff` (only `./react/diff-viewer`),
|
|
169
|
+
* `marked` (only `./react/markdown-doc`), `react-grid-layout` (only
|
|
170
|
+
* `./react/dashboard-grid`) and `@hookform/resolvers` (only `lib/form.ts`). They cost a
|
|
171
|
+
* `Button` consumer **31 of 127 packages and 56 of 231 MB** and bought it nothing. All
|
|
172
|
+
* eight are optional peers at 4.0.0, and this test is what stops the ninth.
|
|
173
|
+
*
|
|
174
|
+
* 🔴 It is also the reason the twelve leaf Radix packages, `clsx`, `tailwind-merge` and
|
|
175
|
+
* `class-variance-authority` STAYED hard dependencies against task 149's own 23-row
|
|
176
|
+
* list: the barrel reaches every one of them, so by this rule they are correctly
|
|
177
|
+
* declared. `docs/STANDARDS.md` carries the measurement that says what moving them
|
|
178
|
+
* anyway would have bought (3 MB) and cost (`Button`, `Tooltip`, `cn`, and eleven more
|
|
179
|
+
* components out of `cursedbelt/react`). Read it before re-opening that question.
|
|
180
|
+
*
|
|
181
|
+
* The two `@atlaskit/pragmatic-drag-and-drop-{auto-scroll,hitbox}` packages are on that
|
|
182
|
+
* list too, and they are stuck for a different reason worth writing down once: `hitbox`'s
|
|
183
|
+
* `getReorderDestinationIndex` is called by `reorderByEdge`
|
|
184
|
+
* (`src/react/hooks/useDragReorder.tsx:112`), an exported **pure synchronous** function, and
|
|
185
|
+
* `attachClosestEdge` runs inside a drop target's `getData`, which the engine calls
|
|
186
|
+
* synchronously mid-drag. Making either lazy changes a public pure function's signature to
|
|
187
|
+
* async, for the 3 packages the pair costs. `DataTable`'s row drag puts the base engine in
|
|
188
|
+
* the barrel regardless, so moving the drag hooks behind a subpath would not free it either.
|
|
189
|
+
*
|
|
190
|
+
* Verified failing before it was trusted: reverting `emoji-mart` to a `dependency` reds
|
|
191
|
+
* with *"declared as runtime `dependencies` but `cursedbelt/react` never reaches them"*.
|
|
192
|
+
*/
|
|
193
|
+
const DEPS_THE_BARREL_NEED_NOT_REACH: Record<string, string> = {
|
|
194
|
+
// The one honest exception, and it is not a JS dependency at all: `src/styles.css`
|
|
195
|
+
// pulls it with a CSS `@import`, so no JS bundle can ever contain it. Deleting it
|
|
196
|
+
// ships a stylesheet that cannot resolve — see `declaredDepsAreImported.spec.ts`,
|
|
197
|
+
// which made exactly that mistake on its first pass.
|
|
198
|
+
'tw-animate-css': '@import-ed from src/styles.css; never reachable from a JS bundle',
|
|
133
199
|
};
|
|
134
200
|
|
|
135
201
|
|
|
@@ -152,3 +218,45 @@ describe('barrels reach no optional peer statically', () => {
|
|
|
152
218
|
});
|
|
153
219
|
}
|
|
154
220
|
});
|
|
221
|
+
|
|
222
|
+
describe('every runtime dependency is one the barrel actually needs', () => {
|
|
223
|
+
const reached = staticExternalsOf('src/react/index.ts');
|
|
224
|
+
const declared = Object.keys(
|
|
225
|
+
(pkg as { dependencies?: Record<string, string> }).dependencies ?? {},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
it('has measured a real barrel', () => {
|
|
229
|
+
// Guards the assertion below the same way the optional-peer half guards its own: an
|
|
230
|
+
// empty `reached` set would make every declared dependency look unreachable, and an
|
|
231
|
+
// empty `declared` list would make the test pass having proved nothing.
|
|
232
|
+
expect(reached.size, 'the bundle scan found no external imports at all').toBeGreaterThan(20);
|
|
233
|
+
expect(declared.length, 'package.json declares no runtime dependencies').toBeGreaterThan(20);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('declares no dependency `cursedbelt/react` cannot reach', () => {
|
|
237
|
+
const unreachable = declared
|
|
238
|
+
.filter((name) => !reached.has(name) && DEPS_THE_BARREL_NEED_NOT_REACH[name] === undefined)
|
|
239
|
+
.sort();
|
|
240
|
+
expect(
|
|
241
|
+
unreachable,
|
|
242
|
+
'these are declared as runtime `dependencies` but `cursedbelt/react` never reaches them, ' +
|
|
243
|
+
'so every consumer installs them to render a Button and no barrel import can ever use ' +
|
|
244
|
+
'them. Make each one an OPTIONAL peer (`peerDependencies` + `peerDependenciesMeta.' +
|
|
245
|
+
'<name>.optional` + a `devDependencies` entry so this repo still typechecks it), which ' +
|
|
246
|
+
'is what `recharts` and the CodeMirror set already are. If it genuinely cannot be ' +
|
|
247
|
+
'reached from JS at all, add it to DEPS_THE_BARREL_NEED_NOT_REACH with the reason:\n ' +
|
|
248
|
+
unreachable.join('\n '),
|
|
249
|
+
).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('keeps no excuse that has stopped being true', () => {
|
|
253
|
+
// An allowlist entry for a package that is no longer declared — or that the barrel now
|
|
254
|
+
// reaches anyway — is an excuse nobody re-reads. Same ratchet as `check-paths.ts`.
|
|
255
|
+
const stale = Object.keys(DEPS_THE_BARREL_NEED_NOT_REACH).filter(
|
|
256
|
+
(name) => !declared.includes(name) || reached.has(name),
|
|
257
|
+
);
|
|
258
|
+
expect(stale, 'DEPS_THE_BARREL_NEED_NOT_REACH excuses a dependency that needs no excuse').toEqual(
|
|
259
|
+
[],
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 🔴 **The document a reader reads first may not claim a tier this package does not ship.**
|
|
7
|
+
*
|
|
8
|
+
* `cursedbelt` was one package and is now three: this React design system,
|
|
9
|
+
* `cursedbelt-core` beneath it, and the Bun/Hono server in `cursedbelt-server`.
|
|
10
|
+
* `package.json`'s `description` says so. `docs/API-REFERENCE.md` did not — until
|
|
11
|
+
* 2026-09-15 its "Foundation choices" list read:
|
|
12
|
+
*
|
|
13
|
+
* > - Server: **Hono on Bun** (one framework). … DB: SQLite (bun:sqlite) + a
|
|
14
|
+
* > migration runner + Kysely-typed plumbing.
|
|
15
|
+
*
|
|
16
|
+
* That cost a real session real time: a task to build a **server-side request-telemetry
|
|
17
|
+
* sink** (Analytics Engine when bound, SQLite when not) was written against `cursedbelt`
|
|
18
|
+
* and fenced to it, on the strength of that sentence. The seam belongs in — and landed
|
|
19
|
+
* in — `cursedbelt-server@1.1.0`. A stale ownership claim in a doc is not a typo; it
|
|
20
|
+
* routes work to the wrong repo, where the gate cannot prove it (generation rule 3).
|
|
21
|
+
*
|
|
22
|
+
* So this is the check rather than a corrected sentence. Two halves, and BOTH matter:
|
|
23
|
+
* the code really is server-free, and the doc really says so.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ROOT = join(import.meta.dir, '..');
|
|
27
|
+
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Terms that name the server tier. If one appears in the doc, the SAME LINE must name the
|
|
31
|
+
* package that owns it — otherwise the line reads as a claim about this package.
|
|
32
|
+
*/
|
|
33
|
+
const SERVER_TIER_TERMS = [
|
|
34
|
+
'bun:sqlite',
|
|
35
|
+
'Hono on Bun',
|
|
36
|
+
'migration runner',
|
|
37
|
+
'Kysely',
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** The packages a server-tier mention is allowed to be attributed to. */
|
|
41
|
+
const OWNERS = ['cursedbelt-server', 'cursedbelt-core'] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The per-line excuse, because the rule has one honest false-positive class: a sentence
|
|
45
|
+
* that mentions a term in order to DENY it ("nothing here imports `bun:sqlite`") reads to
|
|
46
|
+
* a line matcher exactly like a claim. The generation's convention is to excuse the line
|
|
47
|
+
* and keep the rule — never to delete the rule — so this is spelled the same way
|
|
48
|
+
* `check-paths.ts` spells its own: an inline HTML comment carrying a reason.
|
|
49
|
+
*
|
|
50
|
+
* <!-- split:ignore the reason -->
|
|
51
|
+
*/
|
|
52
|
+
const IGNORE = /<!--\s*split:ignore\b/;
|
|
53
|
+
|
|
54
|
+
describe('this package ships no server tier', () => {
|
|
55
|
+
it('no file under src/ imports bun:sqlite or hono', async () => {
|
|
56
|
+
const offenders: string[] = [];
|
|
57
|
+
const files = new Bun.Glob('**/*.{ts,tsx}').scanSync({ cwd: join(ROOT, 'src') });
|
|
58
|
+
for (const rel of files) {
|
|
59
|
+
const text = readFileSync(join(ROOT, 'src', rel), 'utf8');
|
|
60
|
+
// Import/require of the module itself — not a mention inside a comment, which is
|
|
61
|
+
// how this very file talks about the split.
|
|
62
|
+
if (/(?:from|require\()\s*['"](?:bun:sqlite|hono(?:\/[^'"]*)?)['"]/.test(text)) {
|
|
63
|
+
offenders.push(`src/${rel}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
expect(offenders).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('docs/API-REFERENCE.md does not claim the server tier', () => {
|
|
71
|
+
const doc = read('docs/API-REFERENCE.md');
|
|
72
|
+
|
|
73
|
+
it('attributes every server-tier mention to the package that owns it', () => {
|
|
74
|
+
const unattributed: string[] = [];
|
|
75
|
+
for (const [i, line] of doc.split('\n').entries()) {
|
|
76
|
+
if (!SERVER_TIER_TERMS.some((t) => line.includes(t))) continue;
|
|
77
|
+
if (OWNERS.some((owner) => line.includes(owner))) continue;
|
|
78
|
+
if (IGNORE.test(line)) continue;
|
|
79
|
+
unattributed.push(`docs/API-REFERENCE.md:${i + 1}: ${line.trim()}`);
|
|
80
|
+
}
|
|
81
|
+
// A line naming `bun:sqlite` or the migration runner WITHOUT naming
|
|
82
|
+
// `cursedbelt-server` is the 2026-09-15 defect, exactly.
|
|
83
|
+
expect(unattributed).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('says plainly, near the top, that the server tier is elsewhere', () => {
|
|
87
|
+
const top = doc.split('\n').slice(0, 20).join('\n');
|
|
88
|
+
expect(top).toContain('cursedbelt-server');
|
|
89
|
+
expect(top).toMatch(/ships NO server tier/i);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("agrees with package.json's own description of the split", () => {
|
|
93
|
+
const { description } = JSON.parse(read('package.json')) as { description: string };
|
|
94
|
+
// The description is the claim the registry serves; the doc must not contradict it.
|
|
95
|
+
expect(description).toContain('cursedbelt-server');
|
|
96
|
+
expect(doc).toContain('cursedbelt-server');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 The check three apps, then four, then five, were paying for the absence of:
|
|
3
|
+
* what a consuming app EMITS, measured from inside the package, and refused when
|
|
4
|
+
* it grows.
|
|
5
|
+
*
|
|
6
|
+
* ## Why it has to be here, and why it has to be a fixture
|
|
7
|
+
*
|
|
8
|
+
* `flix`, `patterns`, `collections`, `music` and `family` all ship the union of
|
|
9
|
+
* this design system. Every measurement of that cost was taken in a consumer, in a
|
|
10
|
+
* repo that cannot change the thing causing it — and none of them was taken twice,
|
|
11
|
+
* so nothing could tell whether the number was going up. This generation's third
|
|
12
|
+
* rule forbids the obvious fix: a repo's gate proves that repo, so no check here
|
|
13
|
+
* may reach into `apps/*`.
|
|
14
|
+
*
|
|
15
|
+
* So the package keeps its own consumer. `demo/fixtures/lean-app` is a small
|
|
16
|
+
* releases console — header, stat tiles, a chart, a sortable table, a virtualized
|
|
17
|
+
* log — built entirely out of cursedbelt's public subpaths, and
|
|
18
|
+
* `demo/fixtures/lean-app/app.css` is the stylesheet an app copies. Not the
|
|
19
|
+
* kitchen-sink demo: the demo renders every component, so its CSS can never
|
|
20
|
+
* shrink and a ratchet on it would measure nothing.
|
|
21
|
+
*
|
|
22
|
+
* ## The two halves, because either one alone is a lie
|
|
23
|
+
*
|
|
24
|
+
* SIZE — the fixture's CSS on the area route is at or below the byte count in
|
|
25
|
+
* `fixtureAppCss.baseline`, and materially below the union route it
|
|
26
|
+
* replaces. `bun scripts/fixtureAppCss.ts --prune` lowers the baseline
|
|
27
|
+
* and refuses to raise it.
|
|
28
|
+
* TRUTH — the area route still emits every rule the fixture needs, and every
|
|
29
|
+
* cursedbelt module the fixture reaches is covered by an area its
|
|
30
|
+
* stylesheet declares. A stylesheet can always be made smaller by
|
|
31
|
+
* emitting less than the app renders; that is what "unstyled" IS, and it
|
|
32
|
+
* is the only failure mode this whole mechanism has.
|
|
33
|
+
*
|
|
34
|
+
* `scripts/fixtureAppCss.ts` owns the measurement and the baseline format; this
|
|
35
|
+
* file is the WHETHER.
|
|
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 {
|
|
41
|
+
BASELINE,
|
|
42
|
+
FIXTURE,
|
|
43
|
+
ROUTES,
|
|
44
|
+
areaRouteCss,
|
|
45
|
+
declaredAreas,
|
|
46
|
+
fixtureClosure,
|
|
47
|
+
fixtureImports,
|
|
48
|
+
measure,
|
|
49
|
+
missingNeeds,
|
|
50
|
+
readBaseline,
|
|
51
|
+
requiredCss,
|
|
52
|
+
uncoveredModules,
|
|
53
|
+
unionRouteCss,
|
|
54
|
+
} from '../scripts/fixtureAppCss';
|
|
55
|
+
import { REPO_ROOT } from '../scripts/generateStaticStyles';
|
|
56
|
+
import { CORE } from '../scripts/styleAreas';
|
|
57
|
+
|
|
58
|
+
const exportsMap = (pkg as { exports: Record<string, unknown> }).exports;
|
|
59
|
+
const declared = await declaredAreas();
|
|
60
|
+
const baseline = await readBaseline();
|
|
61
|
+
const { union, areas } = await measure();
|
|
62
|
+
|
|
63
|
+
/** The one command that changes any number below, as a line the owner can paste. */
|
|
64
|
+
const REPRUNE = `cd ${REPO_ROOT} && bun scripts/fixtureAppCss.ts --prune`;
|
|
65
|
+
|
|
66
|
+
describe('the fixture app measures a real consumer', () => {
|
|
67
|
+
it('imports cursedbelt the way an app does', async () => {
|
|
68
|
+
// Vacuity guard for the whole file: a fixture that imports nothing emits no
|
|
69
|
+
// cursedbelt CSS, so every size assertion below would pass triumphantly.
|
|
70
|
+
const imports = await fixtureImports();
|
|
71
|
+
expect(imports.length, `${FIXTURE} imports no cursedbelt subpath`).toBeGreaterThan(2);
|
|
72
|
+
const { modules, resolution } = await fixtureClosure(exportsMap);
|
|
73
|
+
expect(
|
|
74
|
+
resolution.unresolved,
|
|
75
|
+
`these imports in ${FIXTURE} cannot be attributed to a module, so no area can be\n` +
|
|
76
|
+
`checked against them:\n ${resolution.unresolved.join('\n ')}`,
|
|
77
|
+
).toEqual([]);
|
|
78
|
+
expect(modules.size, 'the fixture reaches almost nothing — it is not a realistic app').toBeGreaterThan(30);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('declares core first, and the `needs` of everything it declares', () => {
|
|
82
|
+
expect(declared[0], `${FIXTURE}/app.css must import styles-areas/${CORE}.css first`).toBe(CORE);
|
|
83
|
+
expect(new Set(declared).size, 'an area is imported twice').toBe(declared.length);
|
|
84
|
+
const missing = missingNeeds(declared);
|
|
85
|
+
expect(
|
|
86
|
+
missing,
|
|
87
|
+
`${FIXTURE}/app.css declares an area without the areas it composes, so the parts of\n` +
|
|
88
|
+
`it that are subtracted into those areas render unstyled:\n ${missing.join('\n ')}`,
|
|
89
|
+
).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('declares an area for every cursedbelt module it reaches', async () => {
|
|
93
|
+
// 🔴 The assertion that stops the saving being fake. Under-declaring is the
|
|
94
|
+
// mistake an app makes — add a component, forget the `@import` — and its
|
|
95
|
+
// symptom is a component with no classes, three repos away, on a screen nobody
|
|
96
|
+
// opened during review.
|
|
97
|
+
const uncovered = await uncoveredModules(declared, exportsMap);
|
|
98
|
+
expect(
|
|
99
|
+
uncovered,
|
|
100
|
+
`${FIXTURE} renders these cursedbelt modules, and ${FIXTURE}/app.css imports no area\n` +
|
|
101
|
+
'that ships their CSS — so they render UNSTYLED. Add the area named beside each:\n ' +
|
|
102
|
+
uncovered.join('\n '),
|
|
103
|
+
).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('the fixture app is materially smaller on the area route', () => {
|
|
108
|
+
it('has a baseline recording both routes', () => {
|
|
109
|
+
// A missing baseline must fail, not seed itself: a check that writes its own
|
|
110
|
+
// expectation the first time it runs can never fail again.
|
|
111
|
+
for (const route of ROUTES) {
|
|
112
|
+
expect(
|
|
113
|
+
baseline.counts.get(route),
|
|
114
|
+
`${BASELINE} records no \`${route}\` byte count. Seed it:\n ${REPRUNE}`,
|
|
115
|
+
).toBeGreaterThan(0);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not emit more CSS than the baseline allows', () => {
|
|
120
|
+
const grown = ROUTES.map((route) => ({ route, now: route === 'union' ? union : areas, was: baseline.counts.get(route) ?? 0 }))
|
|
121
|
+
.filter((r) => r.now > r.was)
|
|
122
|
+
.map((r) => `${r.route}: baseline allows ${r.was} B, it emits ${r.now} B now (+${r.now - r.was})`);
|
|
123
|
+
expect(
|
|
124
|
+
grown,
|
|
125
|
+
`the CSS a consuming app ships GREW. This is the check that exists because nothing\n` +
|
|
126
|
+
'measured it while five apps paid for the union. If the growth is deliberate — a new\n' +
|
|
127
|
+
`primitive in \`${CORE}\`, a component the fixture started rendering — raise the number in\n` +
|
|
128
|
+
`${BASELINE} by hand, in a commit that says why; \`--prune\` only lowers.\n ` +
|
|
129
|
+
grown.join('\n '),
|
|
130
|
+
).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('is a real saving, not a rounding error', () => {
|
|
134
|
+
// The ratio, not the absolute number: a ratchet on one count cannot tell a
|
|
135
|
+
// genuine saving from a design system that happened to shrink.
|
|
136
|
+
expect(union, 'the union route emitted almost nothing — the compile is broken').toBeGreaterThan(100_000);
|
|
137
|
+
expect(
|
|
138
|
+
areas / union,
|
|
139
|
+
`the area route emits ${areas} B against the union's ${union} B. If this stops being a\n` +
|
|
140
|
+
'material saving, the area map has collapsed back into the union and the split is\n' +
|
|
141
|
+
'carrying its cost for nothing.',
|
|
142
|
+
).toBeLessThan(0.8);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('the smaller stylesheet is not an unstyled one', () => {
|
|
147
|
+
it('emits every rule the fixture actually needs', async () => {
|
|
148
|
+
// The half a byte count cannot see. `requiredCss` is the fixture's own classes
|
|
149
|
+
// plus exactly the classes of the cursedbelt modules it reaches — nothing else
|
|
150
|
+
// — so anything missing here is a rule that would not reach the browser.
|
|
151
|
+
const [required, actual] = await Promise.all([requiredCss(exportsMap), areaRouteCss(declared)]);
|
|
152
|
+
const need = flatten(required);
|
|
153
|
+
const have = flatten(actual);
|
|
154
|
+
expect(need.size, 'the required stylesheet is empty — the comparison would be vacuous').toBeGreaterThan(200);
|
|
155
|
+
const readByRequired = referenced(required);
|
|
156
|
+
const { rules, declarations } = lostRules(need, have, (prop) => !readByRequired.has(prop));
|
|
157
|
+
expect(
|
|
158
|
+
rules,
|
|
159
|
+
`${FIXTURE} renders these selectors and its own stylesheet does not emit them —\n` +
|
|
160
|
+
'the app is UNSTYLED in exactly those places. Usually a missing area in\n' +
|
|
161
|
+
`${FIXTURE}/app.css:\n ${rules.slice(0, 20).join('\n ')}`,
|
|
162
|
+
).toEqual([]);
|
|
163
|
+
expect(
|
|
164
|
+
declarations,
|
|
165
|
+
`${FIXTURE} emits these selectors but not these declarations, so those components\n` +
|
|
166
|
+
`render PARTLY styled:\n ${declarations.slice(0, 20).join('\n ')}`,
|
|
167
|
+
).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('still emits everything the union route does for the fixture, minus what it does not render', async () => {
|
|
171
|
+
// The negative control for the assertion above: the union route is a superset
|
|
172
|
+
// by construction, so if the area route were somehow LARGER, `requiredCss` is
|
|
173
|
+
// measuring the wrong thing and the proof above is worthless.
|
|
174
|
+
const full = (await unionRouteCss()).length;
|
|
175
|
+
expect(areas).toBeLessThan(full);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -54,6 +54,28 @@ const FILE_EXT = /\.(?!css$)[a-z0-9]+$/i;
|
|
|
54
54
|
|
|
55
55
|
const exported = new Set(Object.keys(pkg.exports).map((k) => k.replace(/^\./, '')));
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A mention that STOPS at a placeholder, inside a subpath family that exists
|
|
59
|
+
* (2026-09-15, the `styles-areas/*` split).
|
|
60
|
+
*
|
|
61
|
+
* `@import "cursedbelt/styles-areas/${name}.css"` and the prose form
|
|
62
|
+
* `cursedbelt/styles-areas/<area>.css` are not copy-pasteable imports — they name
|
|
63
|
+
* a family, and `NAMED` truncates at the `$`/`<` because neither is a subpath
|
|
64
|
+
* character. Flagging them would mean 32 `subpath-check:ignore` markers on the
|
|
65
|
+
* lines that DOCUMENT the feature, which is how a gate gets turned off.
|
|
66
|
+
*
|
|
67
|
+
* Narrow on purpose, so a removed family still fails: exempt only when the match
|
|
68
|
+
* is a strict prefix of something `exports` actually has AND the next character in
|
|
69
|
+
* the line is the `/` the match could not consume. `cursedbelt/tokens/${x}` is
|
|
70
|
+
* still an offence, because nothing under `./tokens/` is exported.
|
|
71
|
+
*/
|
|
72
|
+
const familyPrefixes = new Set(
|
|
73
|
+
[...exported].flatMap((subpath) => {
|
|
74
|
+
const parts = subpath.split('/').filter(Boolean);
|
|
75
|
+
return parts.slice(0, -1).map((_, i) => `/${parts.slice(0, i + 1).join('/')}`);
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
57
79
|
// This file and the task/report prose quoting the removal are allowed to name them.
|
|
58
80
|
const SELF = 'namedSubpathsResolve.spec.ts';
|
|
59
81
|
|
|
@@ -77,6 +99,8 @@ const offences = (): Promise<Offence[]> => {
|
|
|
77
99
|
const subpath = m[1] ?? '';
|
|
78
100
|
if (exported.has(subpath)) continue;
|
|
79
101
|
if (REPO_DIRS.test(subpath) || FILE_EXT.test(subpath)) continue;
|
|
102
|
+
// A family name followed by the placeholder that truncated the match.
|
|
103
|
+
if (familyPrefixes.has(subpath) && line[(m.index ?? 0) + (m[0]?.length ?? 0)] === '/') continue;
|
|
80
104
|
found.push({ file: `${root}/${rel}`, line: i + 1, subpath, text: line.trim() });
|
|
81
105
|
}
|
|
82
106
|
}
|
|
@@ -106,6 +130,17 @@ describe('subpaths named in this repo resolve', () => {
|
|
|
106
130
|
expect(exported.has('/game')).toBe(false); // removed 2026-09-13
|
|
107
131
|
});
|
|
108
132
|
|
|
133
|
+
it('excuses a placeholder only inside a family that exists', () => {
|
|
134
|
+
// The control for the allowance above: it must not become "anything with a
|
|
135
|
+
// slash in it is fine". `/styles-areas/core.css` is exported, so the family is;
|
|
136
|
+
// `/tokens` was removed in task 33 and nothing under it is.
|
|
137
|
+
expect(familyPrefixes.has('/styles-areas')).toBe(true);
|
|
138
|
+
expect(familyPrefixes.has('/react')).toBe(true);
|
|
139
|
+
expect(familyPrefixes.has('/tokens')).toBe(false);
|
|
140
|
+
// And a whole-file subpath is never a family — `/styles.css` has no children.
|
|
141
|
+
expect(familyPrefixes.has('/styles.css')).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
109
144
|
it('names no subpath package.json does not export', async () => {
|
|
110
145
|
const dead = await offences();
|
|
111
146
|
expect(
|
package/src/publishShape.spec.ts
CHANGED
|
@@ -35,6 +35,23 @@ const emittedInSrc = (): string[] => {
|
|
|
35
35
|
});
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Every `types` condition, as `subpath → target`. Pure, so the assertion below has a
|
|
40
|
+
* negative control that does not need a broken manifest on disk to prove it works.
|
|
41
|
+
*/
|
|
42
|
+
const typesTargets = (exports: Record<string, unknown>): [string, string][] =>
|
|
43
|
+
Object.entries(exports).flatMap(([subpath, entry]) => {
|
|
44
|
+
if (typeof entry !== 'object' || entry === null) return [];
|
|
45
|
+
const target = (entry as Record<string, unknown>).types;
|
|
46
|
+
return typeof target === 'string' ? [[subpath, target] as [string, string]] : [];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** A `types` that is not a declaration file — tsc COMPILES those instead of reading them. */
|
|
50
|
+
const typesThatAreNotDeclarations = (exports: Record<string, unknown>): string[] =>
|
|
51
|
+
typesTargets(exports)
|
|
52
|
+
.filter(([, target]) => !target.endsWith('.d.ts'))
|
|
53
|
+
.map(([subpath, target]) => `${subpath} → ${target}`);
|
|
54
|
+
|
|
38
55
|
describe('publish shape', () => {
|
|
39
56
|
it('ships every directory its exports resolve into', () => {
|
|
40
57
|
const files = (pkg as { files?: string[] }).files ?? [];
|
|
@@ -86,4 +103,54 @@ describe('publish shape', () => {
|
|
|
86
103
|
.map(([k]) => k);
|
|
87
104
|
expect(wrong, `"types" must be the first condition:\n ${wrong.join('\n ')}`).toEqual([]);
|
|
88
105
|
});
|
|
106
|
+
|
|
107
|
+
it('🔴 points every "types" condition at a .d.ts, never at raw source', () => {
|
|
108
|
+
// ── What this cost, found 2026-09-15, fixed in task 172 ────────────────────────
|
|
109
|
+
// `./guardrails` shipped `"types": "./scripts/guardrailsEnforce.ts"` — the SOURCE.
|
|
110
|
+
// A consumer's `tsc` does not READ that the way it reads a declaration file, it
|
|
111
|
+
// pulls it into the consumer's own program and COMPILES it, under the consumer's
|
|
112
|
+
// `@types/node` and the consumer's strictness. `skipLibCheck` cannot help: it skips
|
|
113
|
+
// `.d.ts`, and this is not one, so there is no flag an app can set to opt out.
|
|
114
|
+
//
|
|
115
|
+
// So one wrong line here — `let entries: ReturnType<typeof readdirSync>`, which
|
|
116
|
+
// silently changed meaning when @types/node 24 made `Dirent` generic — became five
|
|
117
|
+
// errors in four apps' gates, in a file none of them wrote, that this repo's own
|
|
118
|
+
// typecheck had never compiled. `apps/flix`'s `bun run typecheck` was red on `main`
|
|
119
|
+
// for a reason that had nothing to do with flix.
|
|
120
|
+
//
|
|
121
|
+
// The type surface a package publishes must be its own compiler's OUTPUT, not its
|
|
122
|
+
// input: `dist/**/*.d.ts` is already checked here, by us, before it is emitted.
|
|
123
|
+
const raw = typesThatAreNotDeclarations(pkg.exports as unknown as Record<string, unknown>);
|
|
124
|
+
expect(
|
|
125
|
+
raw,
|
|
126
|
+
'these "types" conditions hand a consumer something tsc will COMPILE rather than read,\n' +
|
|
127
|
+
'so every consumer inherits our type errors and cannot suppress one (skipLibCheck skips\n' +
|
|
128
|
+
'.d.ts, and these are not). Emit a declaration into dist and point at that:\n ' +
|
|
129
|
+
raw.join('\n '),
|
|
130
|
+
).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('catches a raw-source "types" rather than passing on an empty manifest', () => {
|
|
134
|
+
// The negative control for the rule above, and it is the export that actually WAS
|
|
135
|
+
// broken. Without this, `typesThatAreNotDeclarations` could quietly stop matching
|
|
136
|
+
// anything — the assertion above would still be green, against nothing.
|
|
137
|
+
expect(
|
|
138
|
+
typesThatAreNotDeclarations({
|
|
139
|
+
'./guardrails': { types: './scripts/guardrailsEnforce.ts' },
|
|
140
|
+
'./button': { types: './dist/react/components/Button.d.ts' },
|
|
141
|
+
'./styles.css': './src/styles.css',
|
|
142
|
+
}),
|
|
143
|
+
).toEqual(['./guardrails → ./scripts/guardrailsEnforce.ts']);
|
|
144
|
+
// A `.tsx` component entry point is the same defect wearing the other extension,
|
|
145
|
+
// and a `.js` is one too — a consumer gets inferred-from-JS types or none at all.
|
|
146
|
+
expect(typesThatAreNotDeclarations({ './a': { types: './src/react/A.tsx' } })).toHaveLength(1);
|
|
147
|
+
expect(typesThatAreNotDeclarations({ './a': { types: './dist/a.js' } })).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('reads the real manifest, so the rule cannot pass vacuously', () => {
|
|
151
|
+
// `types` is what every conditional export in this package leads with; if the scan
|
|
152
|
+
// ever returns nothing, both assertions above are green about a manifest they never
|
|
153
|
+
// looked at.
|
|
154
|
+
expect(typesTargets(pkg.exports as unknown as Record<string, unknown>).length).toBeGreaterThan(40);
|
|
155
|
+
});
|
|
89
156
|
});
|