cursedbelt 4.5.0 → 4.7.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/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +19 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.js +18 -3
- package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
- package/dist/react/media/previewGate.d.ts +36 -0
- package/dist/react/media/previewGate.d.ts.map +1 -0
- package/dist/react/media/previewGate.js +161 -0
- package/dist/react/media/previewGate.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +15 -3
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +3 -1
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +64 -9
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +8 -1
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/styles-areas/file-tree.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -1
- package/src/declaredImports.spec.ts +66 -0
- package/src/publicSurface.spec.ts +12 -4
- package/src/react/file-tree/FileTree.tsx +19 -1
- package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
- package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
- package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
- package/src/react/media/previewGate.spec.tsx +144 -0
- package/src/react/media/previewGate.ts +160 -0
- package/src/react/media-gallery/GalleryTable.tsx +14 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
- package/src/react/media-gallery/MediaGallery.tsx +9 -1
- package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
- package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
- package/src/react/media-gallery/types.ts +8 -1
- package/src/styles-areas/file-tree.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- package/scripts/publicSurface.ts +0 -458
package/scripts/publicSurface.ts
DELETED
|
@@ -1,458 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* `bun scripts/publicSurface.ts [--prune]` — refuse a public surface that grows
|
|
4
|
-
* without a recorded reason.
|
|
5
|
-
*
|
|
6
|
-
* ## What this exists for
|
|
7
|
-
*
|
|
8
|
-
* Measured 2026-09-13 across all 845 consumer files on this machine that mention
|
|
9
|
-
* `cursedbelt`: `1.0.0` published **108 export subpaths and 3,359 exported
|
|
10
|
-
* symbols**, of which every consumer together imported **406 — 12.1%**.
|
|
11
|
-
* `cursedbelt/react` alone exposed 1,037 and 178 were used. Nothing objected at
|
|
12
|
-
* any single step, because no single step was large; the owner's own account of
|
|
13
|
-
* how it got there is *"worked on over many projects with tons of features
|
|
14
|
-
* changing"*.
|
|
15
|
-
*
|
|
16
|
-
* 🔴 **This is NOT a dead-code check, and 12.1% is not a bug to delete.** Most of
|
|
17
|
-
* that surface is alive: the `react` barrel re-exports components that live
|
|
18
|
-
* imports keep mounted, and a design system is SUPPOSED to ship more than any one
|
|
19
|
-
* app has adopted. Deleting everything safely deletable removed 4% of the package
|
|
20
|
-
* (tasks 32/33). What was missing was not restraint, it was an INSTRUMENT — the
|
|
21
|
-
* total was never measured anywhere, so growth was invisible at the only moment
|
|
22
|
-
* anyone could have judged it. This file makes growth visible and deliberate; it
|
|
23
|
-
* does not forbid it.
|
|
24
|
-
*
|
|
25
|
-
* Both rows below are a DATED RECORD, not a live claim — the live number is
|
|
26
|
-
* `publicSurface.baseline`, checked against the tree every run, and it can only
|
|
27
|
-
* go down from the second row:
|
|
28
|
-
*
|
|
29
|
-
* subpaths symbols dependencies peers
|
|
30
|
-
* 1.0.0, before tasks 32/33 108 4,167 47 51
|
|
31
|
-
* seeded 2026-09-13 85 3,808 44 47
|
|
32
|
-
*
|
|
33
|
-
* 🔴 Both rows were measured by THIS file, run against `c01b119` and against the
|
|
34
|
-
* tree it was seeded from, so they are commensurable with each other and with
|
|
35
|
-
* every future run. They are NOT the 3,359 in the task that asked for this file:
|
|
36
|
-
* that number came from an independent walk of the same shape, and this one counts
|
|
37
|
-
* 4,167 for the same tree. The gap is method, not surface — a `default` export,
|
|
38
|
-
* a destructured `export const {a, b}`, and a bare `export * from 'cwip/layout'`
|
|
39
|
-
* are each counted here and were not counted there. Which is the point of the
|
|
40
|
-
* "What it counts, exactly" section below: two numbers that do not say what they
|
|
41
|
-
* counted cannot be compared, and only one of them can be a ratchet.
|
|
42
|
-
*
|
|
43
|
-
* `subpaths` excludes the three stylesheet exports, which have no symbols; the
|
|
44
|
-
* `exports` map has three more keys than the count above. The dependency columns
|
|
45
|
-
* are the second half of the same failure and are asserted by
|
|
46
|
-
* `src/declaredDepsAreImported.spec.ts` — see "The dependency half" below.
|
|
47
|
-
*
|
|
48
|
-
* This file is the reason that second row cannot quietly become the first one
|
|
49
|
-
* again: **document the WHY; automate the WHETHER.** A paragraph in a doc asking
|
|
50
|
-
* the next agent to think about whether a new subpath is needed is the failure
|
|
51
|
-
* mode, not the fix.
|
|
52
|
-
*
|
|
53
|
-
* ## The ratchet
|
|
54
|
-
*
|
|
55
|
-
* `publicSurface.baseline` holds a COUNT PER SUBPATH, the same shape as
|
|
56
|
-
* `tools/check-paths.baseline`, which is the mechanism this generation already
|
|
57
|
-
* trusts. Four ways to fail, and the last two are what make it a ratchet rather
|
|
58
|
-
* than a high-water mark:
|
|
59
|
-
*
|
|
60
|
-
* 1. a subpath in `exports` that the baseline does not list → NEW SURFACE
|
|
61
|
-
* 2. a listed subpath whose symbol count went UP → GREW
|
|
62
|
-
* 3. a listed subpath whose count went DOWN, or that is gone → run --prune
|
|
63
|
-
* 4. an entry matching no subpath at all → run --prune
|
|
64
|
-
*
|
|
65
|
-
* (3) and (4) mean a deleted module cannot leave a stale allowance behind for the
|
|
66
|
-
* next module to grow into. Inherited surface can only fall.
|
|
67
|
-
*
|
|
68
|
-
* `--prune` records the burn-down in one deliberate command and REFUSES to raise
|
|
69
|
-
* anything, so a genuine new export is `--prune`-then-review rather than a number
|
|
70
|
-
* edited by hand, and it shows up in the diff as surface.
|
|
71
|
-
*
|
|
72
|
-
* ## 🔴 What it counts, exactly — and what it does not
|
|
73
|
-
*
|
|
74
|
-
* A count is only worth having if it says what it counted; a count that silently
|
|
75
|
-
* measures less than it claims is a guess wearing a measurement's clothes. This
|
|
76
|
-
* walks, per export subpath in `package.json`, the TypeScript AST of the entry
|
|
77
|
-
* module and every `export * from` beneath it, transitively, deduped by name:
|
|
78
|
-
*
|
|
79
|
-
* · `export const/let/var/function/class/interface/type/enum/namespace`
|
|
80
|
-
* · `export { x }`, `export { x as y }`, `export type { x }`
|
|
81
|
-
* · `export * as ns from './m'` (one symbol: `ns`)
|
|
82
|
-
* · `export default` / `export default function Foo` (one symbol: `default`)
|
|
83
|
-
* · `export * from './m'` (transitive; cycles are visited once)
|
|
84
|
-
* · `export * from 'some-package'` — RESOLVED and walked, not skipped. There is
|
|
85
|
-
* one today (`src/core/layout/index.ts` re-exports `cwip/layout`) and a
|
|
86
|
-
* package's symbols re-exported under this package's name are this package's
|
|
87
|
-
* surface. If such a specifier ever resolves to something this cannot parse
|
|
88
|
-
* (a `.js` build with no types), the run FAILS rather than counting zero.
|
|
89
|
-
*
|
|
90
|
-
* Deliberately NOT counted, each because counting it would make the number lie:
|
|
91
|
-
*
|
|
92
|
-
* · non-TS export targets — `./styles.css`, `./theme.css`, `./workspace.css`.
|
|
93
|
-
* A stylesheet has no symbols. They are reported as "asset export(s)" so they
|
|
94
|
-
* cannot vanish from the accounting.
|
|
95
|
-
* · anything not reachable from an `exports` subpath. Internal modules are not
|
|
96
|
-
* public surface. What `src/react/components/**` contains is not the measure;
|
|
97
|
-
* what the `./react` barrel re-exports is.
|
|
98
|
-
* · the two `bin` targets, `cc-verify` and `cc-promote`. 🔴 They ARE public
|
|
99
|
-
* surface — and they are NOT reachable through `exports`, which is exactly why
|
|
100
|
-
* an earlier reachability walk seeded from `exports` alone reported a shipped
|
|
101
|
-
* CLI as removable. A CLI's interface is its argv, not its export list, so
|
|
102
|
-
* counting its exported symbols would measure the wrong thing. Their SHAPE is
|
|
103
|
-
* already asserted by `src/publishShape.spec.ts` ("declares bin paths npm will
|
|
104
|
-
* actually keep"), and nothing here concludes anything is dead — this tool
|
|
105
|
-
* only ever compares `cursedbelt` to its own baseline.
|
|
106
|
-
*
|
|
107
|
-
* ## What this may NOT become
|
|
108
|
-
*
|
|
109
|
-
* · Not a usage check. It must never try to know who imports `cursedbelt`; that
|
|
110
|
-
* is cross-repo, and a repo's gate proves that repo.
|
|
111
|
-
* · Not a ban on growth. New surface is one hand-added line plus a commit
|
|
112
|
-
* message that says why — a reviewable moment, not a wall.
|
|
113
|
-
* · Not a second guard. One check, one baseline, entry criteria in this header.
|
|
114
|
-
*
|
|
115
|
-
* ## The dependency half
|
|
116
|
-
*
|
|
117
|
-
* 47 hard dependencies is the number that decides what a consumer pays, and it is
|
|
118
|
-
* surface by the same argument. It is NOT re-checked here, because it is already
|
|
119
|
-
* checked once: `src/declaredDepsAreImported.spec.ts` fails on any `dependencies`
|
|
120
|
-
* or `peerDependencies` entry that no file under `src/` or `scripts/` imports, and
|
|
121
|
-
* `src/noPathDeps.spec.ts` fails on a `file:`/`link:`/`workspace:`/`portal:` range
|
|
122
|
-
* in any dependency block. Two ratchets that disagree is the guard sprawl this
|
|
123
|
-
* generation is trying to avoid, so this file points at them instead of repeating
|
|
124
|
-
* them.
|
|
125
|
-
*
|
|
126
|
-
* 🔴 If you ever move that check here, keep the property that made it correct: it
|
|
127
|
-
* scans EVERY file type, not just `.ts`. `tw-animate-css` is imported from
|
|
128
|
-
* `src/styles.css` and a `.ts`-only scan calls it dead.
|
|
129
|
-
*
|
|
130
|
-
* Exit 1 on any failure, so it can sit in a verify chain. It sits in the suite
|
|
131
|
-
* instead — `src/publicSurface.spec.ts` — so `bun run verify` needs no new script
|
|
132
|
-
* and cannot be a script somebody forgot to chain.
|
|
133
|
-
*/
|
|
134
|
-
import { dirname, join } from 'node:path';
|
|
135
|
-
import ts from 'typescript';
|
|
136
|
-
|
|
137
|
-
// Resolved from this file, never from `process.cwd()`: the suite imports it and
|
|
138
|
-
// `bun test` can be started from anywhere.
|
|
139
|
-
const ROOT = dirname(import.meta.dir);
|
|
140
|
-
const BASELINE = join(ROOT, 'publicSurface.baseline');
|
|
141
|
-
|
|
142
|
-
const DEFAULT_HEADER = [
|
|
143
|
-
'# Public surface of `cursedbelt`: one line per export subpath, `<exported symbols> <subpath>`.',
|
|
144
|
-
'#',
|
|
145
|
-
'# A CEILING, not an amnesty. The check fails on a subpath NOT listed here, on a listed',
|
|
146
|
-
'# subpath that gained a symbol, and on an entry that no longer matches `package.json`',
|
|
147
|
-
'# exports — so a deleted module cannot leave a stale allowance behind. Surface can only fall.',
|
|
148
|
-
'#',
|
|
149
|
-
'# To record a deliberate change: bun scripts/publicSurface.ts --prune',
|
|
150
|
-
'# It refuses to RAISE anything, so new surface is a reviewed diff, never a silent one.',
|
|
151
|
-
'#',
|
|
152
|
-
'# What a count includes, what it excludes and why, and the measurement this file is a',
|
|
153
|
-
'# ratchet against, are in the header of scripts/publicSurface.ts. Say nothing here that',
|
|
154
|
-
'# the entries below already say.',
|
|
155
|
-
];
|
|
156
|
-
|
|
157
|
-
export type Surface = {
|
|
158
|
-
/** subpath (as written in `exports`) → deduped exported symbol count */
|
|
159
|
-
counts: Map<string, number>;
|
|
160
|
-
/** subpaths skipped because they resolve to a non-TS asset (the three stylesheets) */
|
|
161
|
-
assets: string[];
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const isRelative = (spec: string): boolean => spec.startsWith('./') || spec.startsWith('../');
|
|
165
|
-
const isWalkable = (file: string): boolean => /\.(m|c)?tsx?$/.test(file);
|
|
166
|
-
|
|
167
|
-
/** `./a/b` from `src/x/y.tsx` → the first of `src/a/b.ts(x)`, `src/a/b/index.ts(x)`, … that exists. */
|
|
168
|
-
const resolveRelative = async (fromFile: string, spec: string): Promise<string | null> => {
|
|
169
|
-
const base = join(dirname(fromFile), spec);
|
|
170
|
-
// `./x.js` names `./x.ts` — the spelling Node ESM needs in the emitted file (task 269), which
|
|
171
|
-
// tsc copies verbatim from the source. Tried first so a `.js` never resolves to itself.
|
|
172
|
-
const ts = /\.js$/.test(base) ? [base.replace(/\.js$/, '.ts'), base.replace(/\.js$/, '.tsx')] : [];
|
|
173
|
-
const candidates = [...ts, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`, base];
|
|
174
|
-
for (const c of candidates) {
|
|
175
|
-
if (await Bun.file(c).exists()) return c;
|
|
176
|
-
}
|
|
177
|
-
return null;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* A bare `export * from 'pkg'` — resolved through node resolution so its symbols
|
|
182
|
-
* are counted rather than silently dropped. Throws when the package resolves to
|
|
183
|
-
* something with no parseable types, because a zero there would be a lie.
|
|
184
|
-
*/
|
|
185
|
-
const resolveBare = (fromFile: string, spec: string): string => {
|
|
186
|
-
let resolved: string;
|
|
187
|
-
try {
|
|
188
|
-
resolved = Bun.resolveSync(spec, dirname(fromFile));
|
|
189
|
-
} catch (cause) {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`${fromFile}: \`export * from '${spec}'\` re-exports a package that does not resolve, so its ` +
|
|
192
|
-
'symbols cannot be counted. Install it, or name the symbols — do not let the count lie.',
|
|
193
|
-
{ cause },
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
if (!isWalkable(resolved)) {
|
|
197
|
-
throw new Error(
|
|
198
|
-
`${fromFile}: \`export * from '${spec}'\` resolves to ${resolved}, which this measurement cannot ` +
|
|
199
|
-
'parse. Point it at TypeScript, name the symbols, or teach scripts/publicSurface.ts to read ' +
|
|
200
|
-
'a declaration bundle — do not let the count lie.',
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
return resolved;
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// One parse per file, shared across all 88 subpaths: the barrels overlap heavily
|
|
207
|
-
// (every `./react/*` subpath re-parses a slice of what `./react` already walked).
|
|
208
|
-
const parsed = new Map<string, ts.SourceFile>();
|
|
209
|
-
const sourceOf = async (file: string): Promise<ts.SourceFile> => {
|
|
210
|
-
const hit = parsed.get(file);
|
|
211
|
-
if (hit !== undefined) return hit;
|
|
212
|
-
// 🔴 Bun.file, NOT node:fs — this package's test preload shares one process with
|
|
213
|
-
// specs that virtualize node:fs, so a readFileSync scan reads MOCK data and the
|
|
214
|
-
// measurement passes against files it never opened. Same trap as
|
|
215
|
-
// publishShape.spec.ts; see its header.
|
|
216
|
-
const source = ts.createSourceFile(file, await Bun.file(file).text(), ts.ScriptTarget.ES2022, true);
|
|
217
|
-
parsed.set(file, source);
|
|
218
|
-
return source;
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
/** Every name a binding pattern introduces — `export const { a, b: [c] } = …` is three. */
|
|
222
|
-
const bindingNames = (name: ts.BindingName, out: Set<string>): void => {
|
|
223
|
-
if (ts.isIdentifier(name)) {
|
|
224
|
-
out.add(name.text);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
for (const el of name.elements) {
|
|
228
|
-
if (ts.isBindingElement(el)) bindingNames(el.name, out);
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
/** Deduped exported names of `file`, following `export * from` chains. */
|
|
233
|
-
const exportedNames = async (file: string, seen = new Set<string>()): Promise<Set<string>> => {
|
|
234
|
-
const names = new Set<string>();
|
|
235
|
-
if (seen.has(file)) return names;
|
|
236
|
-
seen.add(file);
|
|
237
|
-
|
|
238
|
-
const source = await sourceOf(file);
|
|
239
|
-
const modifiersOf = (node: ts.Node): readonly ts.Modifier[] =>
|
|
240
|
-
ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : [];
|
|
241
|
-
|
|
242
|
-
for (const st of source.statements) {
|
|
243
|
-
if (ts.isExportDeclaration(st)) {
|
|
244
|
-
if (st.exportClause && ts.isNamedExports(st.exportClause)) {
|
|
245
|
-
for (const el of st.exportClause.elements) names.add(el.name.text);
|
|
246
|
-
} else if (st.exportClause && ts.isNamespaceExport(st.exportClause)) {
|
|
247
|
-
names.add(st.exportClause.name.text);
|
|
248
|
-
} else {
|
|
249
|
-
const spec =
|
|
250
|
-
st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier) ? st.moduleSpecifier.text : null;
|
|
251
|
-
if (spec === null) continue;
|
|
252
|
-
let target: string | null;
|
|
253
|
-
if (isRelative(spec)) {
|
|
254
|
-
target = await resolveRelative(file, spec);
|
|
255
|
-
if (target === null) throw new Error(`${file}: \`export * from '${spec}'\` resolves to nothing.`);
|
|
256
|
-
} else {
|
|
257
|
-
target = resolveBare(file, spec);
|
|
258
|
-
}
|
|
259
|
-
for (const n of await exportedNames(target, seen)) names.add(n);
|
|
260
|
-
}
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
if (ts.isExportAssignment(st)) {
|
|
264
|
-
names.add('default');
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
const modifiers = modifiersOf(st);
|
|
268
|
-
if (!modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
269
|
-
// `export default function Foo()` is imported as `default`, not as `Foo`.
|
|
270
|
-
if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
|
|
271
|
-
names.add('default');
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (ts.isVariableStatement(st)) {
|
|
275
|
-
for (const d of st.declarationList.declarations) bindingNames(d.name, names);
|
|
276
|
-
} else if (
|
|
277
|
-
(ts.isFunctionDeclaration(st) ||
|
|
278
|
-
ts.isClassDeclaration(st) ||
|
|
279
|
-
ts.isInterfaceDeclaration(st) ||
|
|
280
|
-
ts.isTypeAliasDeclaration(st) ||
|
|
281
|
-
ts.isEnumDeclaration(st) ||
|
|
282
|
-
ts.isModuleDeclaration(st)) &&
|
|
283
|
-
st.name !== undefined
|
|
284
|
-
) {
|
|
285
|
-
if (ts.isIdentifier(st.name) || ts.isStringLiteral(st.name)) names.add(st.name.text);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return names;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
/** The `./src/**` or `./scripts/**` TypeScript file a subpath resolves to, or null when it is an asset. */
|
|
292
|
-
const entryOf = (value: unknown): string | null => {
|
|
293
|
-
if (typeof value === 'string')
|
|
294
|
-
return (value.startsWith('./src/') || value.startsWith('./scripts/')) && isWalkable(value)
|
|
295
|
-
? join(ROOT, value.slice(2))
|
|
296
|
-
: null;
|
|
297
|
-
if (typeof value !== 'object' || value === null) return null;
|
|
298
|
-
// `bun` and `source` are the conditions that point at TypeScript; `types` and
|
|
299
|
-
// `import` point into `dist`, which is build output and may not exist. Reading
|
|
300
|
-
// the conditions in this order is what makes the measurement work on a clean
|
|
301
|
-
// checkout, before `bun run build` has ever run.
|
|
302
|
-
for (const key of ['bun', 'source', 'types', 'import', 'default']) {
|
|
303
|
-
if (key in (value as Record<string, unknown>)) {
|
|
304
|
-
const hit = entryOf((value as Record<string, unknown>)[key]);
|
|
305
|
-
if (hit !== null) return hit;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
for (const v of Object.values(value as Record<string, unknown>)) {
|
|
309
|
-
const hit = entryOf(v);
|
|
310
|
-
if (hit !== null) return hit;
|
|
311
|
-
}
|
|
312
|
-
return null;
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
export const measureSurface = async (pkg: { exports: Record<string, unknown> }): Promise<Surface> => {
|
|
316
|
-
const counts = new Map<string, number>();
|
|
317
|
-
const assets: string[] = [];
|
|
318
|
-
for (const [subpath, value] of Object.entries(pkg.exports)) {
|
|
319
|
-
const entry = entryOf(value);
|
|
320
|
-
if (entry === null) {
|
|
321
|
-
assets.push(subpath);
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
counts.set(subpath, (await exportedNames(entry)).size);
|
|
325
|
-
}
|
|
326
|
-
return { counts, assets };
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
// ── the baseline ────────────────────────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
export type Baseline = { header: string[]; counts: Map<string, number> };
|
|
332
|
-
|
|
333
|
-
export const parseBaseline = (text: string): Baseline => {
|
|
334
|
-
const header = text.split('\n').filter((l) => l.startsWith('#'));
|
|
335
|
-
const counts = new Map<string, number>();
|
|
336
|
-
for (const line of text.split('\n')) {
|
|
337
|
-
if (!line.trim() || line.startsWith('#')) continue;
|
|
338
|
-
const m = line.match(/^\s*(\d+)\s+(\S.*)$/);
|
|
339
|
-
if (m?.[2] !== undefined) counts.set(m[2].trim(), Number(m[1]));
|
|
340
|
-
}
|
|
341
|
-
return { header: header.length > 0 ? header : DEFAULT_HEADER, counts };
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
export const readBaseline = async (): Promise<Baseline> => {
|
|
345
|
-
const f = Bun.file(BASELINE);
|
|
346
|
-
return parseBaseline((await f.exists()) ? await f.text() : '');
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
export const formatBaseline = (header: string[], counts: Map<string, number>): string =>
|
|
350
|
-
`${[...header, ...[...counts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${n} ${k}`)].join(
|
|
351
|
-
'\n',
|
|
352
|
-
)}\n`;
|
|
353
|
-
|
|
354
|
-
export type Failures = { grown: string[]; fresh: string[]; stale: string[] };
|
|
355
|
-
|
|
356
|
-
export const compare = (surface: Surface, baseline: Baseline): Failures => {
|
|
357
|
-
const fresh: string[] = [];
|
|
358
|
-
const grown: string[] = [];
|
|
359
|
-
for (const [subpath, n] of surface.counts) {
|
|
360
|
-
const allowed = baseline.counts.get(subpath);
|
|
361
|
-
if (allowed === undefined) fresh.push(`${subpath} — NEW subpath, ${n} exported symbol(s)`);
|
|
362
|
-
else if (n > allowed) grown.push(`${subpath} — baseline allows ${allowed}, exports ${n} now`);
|
|
363
|
-
}
|
|
364
|
-
const stale = [...baseline.counts.entries()]
|
|
365
|
-
.filter(([subpath, n]) => (surface.counts.get(subpath) ?? 0) < n)
|
|
366
|
-
.map(([subpath, n]) =>
|
|
367
|
-
surface.counts.has(subpath)
|
|
368
|
-
? `${subpath} — baseline says ${n}, exports ${surface.counts.get(subpath)}`
|
|
369
|
-
: `${subpath} — baseline says ${n}, the subpath is gone`,
|
|
370
|
-
);
|
|
371
|
-
return { fresh, grown, stale };
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
export const run = async (): Promise<Failures & { surface: Surface; baseline: Baseline }> => {
|
|
375
|
-
const pkg = (await Bun.file(join(ROOT, 'package.json')).json()) as { exports: Record<string, unknown> };
|
|
376
|
-
const surface = await measureSurface(pkg);
|
|
377
|
-
const baseline = await readBaseline();
|
|
378
|
-
return { ...compare(surface, baseline), surface, baseline };
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
// ── CLI ─────────────────────────────────────────────────────────────────────────
|
|
382
|
-
|
|
383
|
-
if (import.meta.main) {
|
|
384
|
-
const argv = process.argv.slice(2);
|
|
385
|
-
const prune = argv.includes('--prune');
|
|
386
|
-
const { fresh, grown, stale, surface, baseline } = await run();
|
|
387
|
-
const total = [...surface.counts.values()].reduce((a, b) => a + b, 0);
|
|
388
|
-
|
|
389
|
-
// `--seed` exists once, to record the surface the ratchet starts from. It REFUSES
|
|
390
|
-
// over an existing baseline, so re-seeding means deleting the file first — two
|
|
391
|
-
// visible steps and a whole-file diff, rather than one command that quietly
|
|
392
|
-
// re-baselines whatever grew.
|
|
393
|
-
if (argv.includes('--seed')) {
|
|
394
|
-
if (baseline.counts.size > 0) {
|
|
395
|
-
console.error(`publicSurface --seed refuses: ${BASELINE} already records ${baseline.counts.size} subpath(s).`);
|
|
396
|
-
console.error('Lower it with --prune. Raising it is a hand edit in a commit that says why.');
|
|
397
|
-
process.exit(1);
|
|
398
|
-
}
|
|
399
|
-
await Bun.write(BASELINE, formatBaseline(baseline.header, surface.counts));
|
|
400
|
-
console.log(`publicSurface: seeded ${surface.counts.size} subpath(s), ${total} exported symbol(s).`);
|
|
401
|
-
process.exit(0);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (prune) {
|
|
405
|
-
// 🔴 It may only LOWER. Growth is the thing this file exists to make visible;
|
|
406
|
-
// a prune that could absorb it would be the disable switch.
|
|
407
|
-
if (fresh.length > 0 || grown.length > 0) {
|
|
408
|
-
console.error('publicSurface --prune refuses while the surface has GROWN — it may only lower the baseline.\n');
|
|
409
|
-
for (const v of [...fresh, ...grown]) console.error(` ${v}`);
|
|
410
|
-
console.error(
|
|
411
|
-
'\nTo record deliberate new surface, add the line by hand and say in the commit message why the\npackage needs it — that is the reviewable moment this check exists to create.\n',
|
|
412
|
-
);
|
|
413
|
-
process.exit(1);
|
|
414
|
-
}
|
|
415
|
-
const lowered = new Map<string, number>();
|
|
416
|
-
for (const [subpath, n] of baseline.counts) {
|
|
417
|
-
const now = surface.counts.get(subpath);
|
|
418
|
-
if (now !== undefined) lowered.set(subpath, Math.min(n, now));
|
|
419
|
-
}
|
|
420
|
-
await Bun.write(BASELINE, formatBaseline(baseline.header, lowered));
|
|
421
|
-
console.log(
|
|
422
|
-
`publicSurface: baseline pruned to ${lowered.size} subpath(s), ` +
|
|
423
|
-
`${baseline.counts.size - lowered.size} removed, ${[...lowered.values()].reduce((a, b) => a + b, 0)} symbols.`,
|
|
424
|
-
);
|
|
425
|
-
process.exit(0);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
let bad = false;
|
|
429
|
-
const report = (label: string, lines: string[], advice: string): void => {
|
|
430
|
-
if (lines.length === 0) return;
|
|
431
|
-
bad = true;
|
|
432
|
-
console.error(`\n${lines.length} ${label}:\n`);
|
|
433
|
-
for (const l of lines) console.error(` ${l}`);
|
|
434
|
-
console.error(`\n${advice}\n`);
|
|
435
|
-
};
|
|
436
|
-
report(
|
|
437
|
-
'export subpath(s) the baseline does not know',
|
|
438
|
-
fresh,
|
|
439
|
-
'A new subpath is a permanent public promise. If it is genuinely wanted, add its line to\n' +
|
|
440
|
-
`${BASELINE} and say why in the commit message.`,
|
|
441
|
-
);
|
|
442
|
-
report(
|
|
443
|
-
'subpath(s) that gained exported symbols',
|
|
444
|
-
grown,
|
|
445
|
-
'A baseline entry is a ceiling. Raise it by hand, in a commit that says why.',
|
|
446
|
-
);
|
|
447
|
-
report(
|
|
448
|
-
'baseline entr(y|ies) that no longer match `exports`',
|
|
449
|
-
stale,
|
|
450
|
-
`Surface came off and the ratchet was not tightened. Run:\n cd ${ROOT} && bun scripts/publicSurface.ts --prune`,
|
|
451
|
-
);
|
|
452
|
-
if (bad) process.exit(1);
|
|
453
|
-
|
|
454
|
-
console.log(
|
|
455
|
-
`publicSurface: ${surface.counts.size} subpath(s), ${total} exported symbol(s), ` +
|
|
456
|
-
`${surface.assets.length} asset export(s) — none grown.`,
|
|
457
|
-
);
|
|
458
|
-
}
|