cursedops 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -13,9 +13,11 @@ bun add cursedops
13
13
  | `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — and the one check no app owns: every address of a deployment serving the same built client |
14
14
  | `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
15
15
  | `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser and the default 404 body. No `node:` import, so it mounts inside a Worker |
16
+ | `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
16
17
 
17
18
  Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
18
- name, a hostname, a port or a route.
19
+ name, a hostname, a port or a route. `typescript` is an **optional** peer: only
20
+ `cursedops/public-surface` imports it, and nothing else here ever loads it.
19
21
 
20
22
  ## 🔴 The ceiling, and why this library has one
21
23
 
@@ -52,6 +54,22 @@ told ONE NAME is a check about that name; this one is about a property every dep
52
54
  with two addresses has. It buys the exception by being pinned to a failure it has
53
55
  actually caught — `src/smoke.test.ts` runs it against the genuine 2026-09-12 shell.
54
56
 
57
+ 🔴 **`public-surface` is the second recorded exception, and the first whose callers are
58
+ not apps.** Rule 1 is met — more than met — but by three LIBRARIES: `cwip`, `cursedbelt`
59
+ and `cursedbelt-server` each carried `scripts/publicSurface.ts` (430, 455 and 476 lines),
60
+ and by 0.2.6 the bodies had already forked, which is what admits it. Measured 2026-09-22:
61
+ `cwip`'s threw on a bare-specifier `export *` where the other two resolved and counted
62
+ it, so the three numbers were not commensurable; and when the ESM-extension fix (task
63
+ 269) made `./x.js` the spelling of an internal specifier, `cursedbelt-server`'s and
64
+ `cursedbelt`'s copies each had to be taught `./x.js` → `./x.ts` by hand, separately,
65
+ while `cwip`'s never was. Each library is published on its own and its gate must pass
66
+ from a lone checkout, so neither a `file:` import nor a reach into `$FORGE/tools` could
67
+ share it; a published version could, and this package is already that for the fleet.
68
+ It is a devDependency of each library, so nothing their consumers install changed. On
69
+ adoption all three measured **identical per-subpath counts** to their own copies
70
+ (`cwip` 60 subpaths/2,089 symbols, `cursedbelt` 51/1,837, `cursedbelt-server` 34/992) —
71
+ a consolidation that moves a count has changed the measurement, not the plumbing.
72
+
55
73
  ### What was deliberately left in the apps
56
74
 
57
75
  The comparison that produced this library found three areas that are genuinely
@@ -245,6 +263,29 @@ input the eight copies refused refused, at the same cost. A symlinked ancestor o
245
263
  root cancels out — which is the failure a one-sided `realpath` would have shipped to
246
264
  every app on this machine, and is its own test.
247
265
 
266
+ ## `cursedops/public-surface`
267
+
268
+ ```sh
269
+ # in the library's package.json — `bun run surface` / `bun run surface --prune`
270
+ "surface": "public-surface" # cwip adds --peers
271
+ ```
272
+
273
+ ```ts
274
+ import { run } from "cursedops/public-surface";
275
+
276
+ const { fresh, grown, stale, peers } = await run({ root: packageDir }); // all four [] when clean
277
+ ```
278
+
279
+ `publicSurface.baseline` at the package root is one `<count> <subpath>` line per
280
+ export subpath. It fails on a subpath the baseline does not list, on one that GREW, and
281
+ on an entry that shrank or matches nothing — so a deleted module cannot leave an
282
+ allowance behind. `--prune` only ever lowers; new surface is a line added by hand in a
283
+ commit that says why. What a count includes and excludes, and the three measurements
284
+ that each produced a copy of this, are in the header of `src/publicSurface.ts`.
285
+
286
+ It is a check a library runs on ITSELF. It never asks who imports the library — that
287
+ is cross-repo, and a repo's gate proves that repo.
288
+
248
289
  ## Verifying
249
290
 
250
291
  ```sh
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.2.5",
4
- "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard among them — and the API floor that keeps an unmatched /api/... from ever being answered with the app shell. Mechanism only — no app knows its name from here. Bun, zero dependencies, ships source.",
3
+ "version": "0.2.6",
4
+ "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard among them — and the API floor that keeps an unmatched /api/... from ever being answered with the app shell — and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -42,12 +42,29 @@
42
42
  "source": "./src/smoke.ts",
43
43
  "import": "./src/smoke.ts"
44
44
  },
45
+ "./public-surface": {
46
+ "types": "./src/publicSurface.ts",
47
+ "bun": "./src/publicSurface.ts",
48
+ "source": "./src/publicSurface.ts",
49
+ "import": "./src/publicSurface.ts"
50
+ },
45
51
  "./package.json": "./package.json"
46
52
  },
53
+ "bin": {
54
+ "public-surface": "./src/publicSurface.ts"
55
+ },
47
56
  "files": [
48
57
  "src",
49
58
  "!src/**/*.test.ts"
50
59
  ],
60
+ "peerDependencies": {
61
+ "typescript": ">=5"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "typescript": {
65
+ "optional": true
66
+ }
67
+ },
51
68
  "devDependencies": {
52
69
  "@biomejs/biome": "^2.3.14",
53
70
  "@types/bun": "^1.3.14",
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `cursedops/public-surface` — refuse a library's public surface that grows without a
4
+ * recorded reason. One measurement, one baseline format, one CLI, for every library
5
+ * that ratchets its surface.
6
+ *
7
+ * ```sh
8
+ * public-surface [--peers] [--seed] [--prune] # the `bin`, run from the package root
9
+ * ```
10
+ *
11
+ * ```ts
12
+ * import { run } from "cursedops/public-surface";
13
+ * const { fresh, grown, stale, peers } = await run({ root: packageDir, peers: true });
14
+ * ```
15
+ *
16
+ * ## What this exists for
17
+ *
18
+ * Three libraries measured the same failure from three ends, and each wrote this file:
19
+ *
20
+ * · `cwip`, 2026-09-13. `cwip@1.1.24` was one entry point and its own description said
21
+ * *"Helpful utility functions with no external dependencies"*. Three months and one
22
+ * reasonable-looking module at a time later, `4.0.0` published **86 subpaths and
23
+ * 3,109 exported symbols**, of which every consumer on this machine imported 480 —
24
+ * 15.4%. 2,629 symbols and nine peer dependencies accumulated inside a green gate.
25
+ * Tasks 26/27/28 then deleted ~17,000 lines of it.
26
+ * · `cursedbelt`, 2026-09-13, across all 845 consumer files on this machine that
27
+ * mention it: `1.0.0` published **108 export subpaths and 3,359 exported symbols**,
28
+ * of which every consumer together imported **406 — 12.1%**. `cursedbelt/react`
29
+ * alone exposed 1,037 and 178 were used. The owner's own account of how it got
30
+ * there is *"worked on over many projects with tons of features changing"*.
31
+ * · `cursedbelt-server`, 2026-09-19 — the one of the three with no ceiling at all.
32
+ * While it was missing that package went from 30 export subpaths at `4.1.0`
33
+ * (`ecab556`) to 34 at `4.10.0` (`0a035b1`); `4.1.0` alone added `./binary-store`
34
+ * and `./binary-store/testing`, 26 exported symbols in one commit, and nothing had
35
+ * to acknowledge it. Walking every named import of it under `$FORGE/{apps,libs,
36
+ * autopilot}` — 202 files — against the symbol sets this file computes: **132 of
37
+ * 836 distinct exported symbols, 15.8%**; eight of 34 subpaths had no importer.
38
+ *
39
+ * The rows each package seeded from, measured by the body that package had then — so
40
+ * each row is commensurable with that package's later runs and NOT with the other two:
41
+ *
42
+ * subpaths symbols peers
43
+ * cwip 4.0.0 86 3,109 13
44
+ * cwip seeded 2026-09-13 57 2,030 6
45
+ * cursedbelt 1.0.0, before tasks 32/33 108 4,167 51
46
+ * cursedbelt seeded 2026-09-13 85 3,808 47
47
+ * cursedbelt-server seeded 2026-09-19 34 991 —
48
+ *
49
+ * (cursedbelt counts 4,167 for the tree the task measured at 3,359. The gap is method,
50
+ * not surface — a `default` export, a destructured `export const {a, b}` and a bare
51
+ * `export * from 'cwip/layout'` are each counted here and were not there. Two numbers
52
+ * that do not say what they counted cannot be compared, and only one can be a ratchet.)
53
+ *
54
+ * 🔴 **This is NOT a dead-code check, and 12–16% used is not a bug to delete.** A design
55
+ * system or a server tier is SUPPOSED to ship more than any one app has adopted, and
56
+ * most of the surface is alive — deleting everything safely deletable from `cursedbelt`
57
+ * removed 4% of it. What was missing was not restraint, it was an INSTRUMENT: the total
58
+ * was never measured, so growth was invisible at the only moment anyone could have
59
+ * judged it. This makes growth visible and deliberate. **Document the WHY; automate the
60
+ * WHETHER.**
61
+ *
62
+ * ## Why one body, and why here
63
+ *
64
+ * Until 0.2.6 this was `scripts/publicSurface.ts` in each of the three libraries, and
65
+ * the third was written knowing it was a third (task 084). The bodies had already
66
+ * forked — which is the whole argument:
67
+ *
68
+ * · `cursedbelt`'s resolved a bare-specifier `export *`, cached one parse per file and
69
+ * read `./scripts/**` entries. `cwip`'s THREW on a bare specifier and had none of the
70
+ * rest, so a `cwip` subpath re-exporting a package was a hard error there and a count
71
+ * in the other two, and the numbers were not commensurable.
72
+ * · On 2026-09-22 the ESM-extension fix (task 269) made `./x.js` the spelling of an
73
+ * internal specifier, and `cursedbelt-server`'s and `cursedbelt`'s copies each had
74
+ * to be taught `./x.js` → `./x.ts` by hand, separately. `cwip`'s never was.
75
+ *
76
+ * Each library is a separately published package whose gate must pass from a lone
77
+ * checkout, which rules out a `file:` import and a reach into `$FORGE/tools` alike. It
78
+ * lives in `cursedops` — already a published, Bun-only, source-shipping dependency — as
79
+ * a devDependency of each library, so nothing a library's consumers install changes.
80
+ * `typescript` is an OPTIONAL peer here: only this subpath imports it, and every
81
+ * library that runs it already has it.
82
+ *
83
+ * ## The ratchet
84
+ *
85
+ * `publicSurface.baseline` at the package root holds a COUNT PER SUBPATH, the same shape
86
+ * as the generation's `tools/check-paths.baseline`. Four ways to fail, and the last two
87
+ * are what make it a ratchet rather than a high-water mark:
88
+ *
89
+ * 1. a subpath in `exports` that the baseline does not list → NEW SURFACE
90
+ * 2. a listed subpath whose symbol count went UP → GREW
91
+ * 3. a listed subpath whose count went DOWN, or that is gone → run --prune
92
+ * 4. an entry matching no subpath at all → run --prune
93
+ *
94
+ * (3) and (4) mean a deleted module cannot leave a stale allowance behind for the next
95
+ * module to grow into. `--prune` records the burn-down in one deliberate command and
96
+ * REFUSES to raise anything, so a genuine new export is a line added BY HAND in a commit
97
+ * that says why. `--seed` exists once per package and refuses over an existing baseline.
98
+ *
99
+ * ## 🔴 What it counts, exactly — and what it does not
100
+ *
101
+ * Per export subpath in `package.json`, the TypeScript AST of the entry module and every
102
+ * `export * from` beneath it, transitively, deduped by name:
103
+ *
104
+ * · `export const/let/var/function/class/interface/type/enum/namespace`
105
+ * · `export { x }`, `export { x as y }`, `export type { x }`
106
+ * · `export * as ns from './m'` (one symbol: `ns`)
107
+ * · `export default` / `export default function Foo` (one symbol: `default`)
108
+ * · `export * from './m'` (transitive; cycles are visited once; `./m.js` names
109
+ * `./m.ts`, the spelling Node ESM needs in emitted code)
110
+ * · `export * from 'some-package'` — RESOLVED and walked. A package's symbols
111
+ * re-exported under this package's name are this package's surface. If it resolves
112
+ * to something this cannot parse (a `.js` build with no types), the run FAILS rather
113
+ * than counting zero.
114
+ *
115
+ * Deliberately NOT counted, each because counting it would make the number lie:
116
+ *
117
+ * · non-TS export targets (a stylesheet, a wasm blob) — no symbols. Reported as
118
+ * "asset export(s)" so they cannot vanish from the accounting.
119
+ * · anything not reachable from an `exports` subpath. Internal modules are not public
120
+ * surface; what a barrel re-exports is.
121
+ * · `bin` targets. 🔴 They ARE public surface and NOT reachable through `exports` —
122
+ * which is why an earlier reachability walk seeded from `exports` alone once
123
+ * reported `cursedbelt`'s shipped `cc-verify` CLI as removable. A CLI's interface is
124
+ * its argv, not its export list.
125
+ * · the SHAPE of the subpaths — each library's `publishShape` spec owns that.
126
+ *
127
+ * Entry resolution reads the `bun`, `source`, `types`, `import`, `default` conditions in
128
+ * that order and takes the first `./src/**` or `./scripts/**` TypeScript file — so it
129
+ * works on a clean checkout before any `build` has written `dist`.
130
+ *
131
+ * ## The peers half (`--peers`, or `peers: true`)
132
+ *
133
+ * Opt-in, because a library that already has a declared-deps spec must not grow a
134
+ * second ratchet that can disagree with it. `cwip` has none, so it asks for this:
135
+ *
136
+ * · every peer is named by a non-test `src` file — as a static/dynamic import, a
137
+ * `requirePeer(...)` call, or an `@peer <name>` pragma;
138
+ * · every peer range is a registry range — a URL, `file:`, `link:`, `git+` or
139
+ * `github:` range is refused.
140
+ *
141
+ * Measured 2026-09-13 on `cwip`: FOUR of thirteen peers matched no `from '<peer>'` line,
142
+ * and `xlsx` was pinned to a raw CDN tarball URL. 🔴 The pragma is why this is not the
143
+ * shell loop that found those four: THREE of its hits were false — `pg`, `mysql2` and
144
+ * `mssql` load through a runtime variable (`loadDriver('mysql2/promise')`) and were
145
+ * already declared with `@peer`. Only `@playwright/test` was orphaned; shipping the
146
+ * loop's answer would have deleted three live peers. Test files and any `testing/`
147
+ * directory are excluded from the scan: a `mock.module('mongodb', …)` is not a use.
148
+ *
149
+ * ## What this may NOT become
150
+ *
151
+ * · Not a usage check. It must never try to know who imports a library; that is
152
+ * cross-repo, and a repo's gate proves that repo.
153
+ * · Not a ban on growth. New surface is one hand-added line plus a commit message.
154
+ * · Not a second guard. One check, one baseline, entry criteria in this header.
155
+ */
156
+ import { dirname, join } from "node:path";
157
+ import ts from "typescript";
158
+ import { findPackageRoot } from "./roots.ts";
159
+
160
+ export type SurfaceOptions = {
161
+ /** The package directory — the one holding `package.json` and the baseline. */
162
+ root: string;
163
+ /** Defaults to `<root>/publicSurface.baseline`. */
164
+ baseline?: string;
165
+ /** Also check `peerDependencies` (see "The peers half"). Off by default. */
166
+ peers?: boolean;
167
+ };
168
+
169
+ type Pkg = {
170
+ name?: string;
171
+ exports?: Record<string, unknown>;
172
+ peerDependencies?: Record<string, string>;
173
+ scripts?: Record<string, string>;
174
+ };
175
+
176
+ export type Surface = {
177
+ /** subpath (as written in `exports`) → deduped exported symbol count */
178
+ counts: Map<string, number>;
179
+ /** subpaths skipped because they resolve to a non-TS asset */
180
+ assets: string[];
181
+ };
182
+
183
+ export type Baseline = { header: string[]; counts: Map<string, number> };
184
+
185
+ export type Failures = { grown: string[]; fresh: string[]; stale: string[]; peers: string[] };
186
+
187
+ const baselineOf = (options: SurfaceOptions): string => options.baseline ?? join(options.root, "publicSurface.baseline");
188
+
189
+ const readPkg = async (root: string): Promise<Pkg> => (await Bun.file(join(root, "package.json")).json()) as Pkg;
190
+
191
+ /**
192
+ * The command a person types to prune THIS package — the `package.json` script that runs
193
+ * the `public-surface` bin, so the advice names what the package actually wired.
194
+ */
195
+ export const pruneCommand = (pkg: Pkg): string => {
196
+ const script = Object.entries(pkg.scripts ?? {}).find(([, body]) => /\bpublic-surface\b/.test(body));
197
+ return script ? `bun run ${script[0]} --prune` : "bunx public-surface --prune";
198
+ };
199
+
200
+ export const defaultHeader = (pkg: Pkg): string[] => [
201
+ `# Public surface of \`${pkg.name ?? "this package"}\`: one line per export subpath, \`<exported symbols> <subpath>\`.`,
202
+ "#",
203
+ "# A CEILING, not an amnesty. The check fails on a subpath NOT listed here, on a listed",
204
+ "# subpath that gained a symbol, and on an entry that no longer matches `package.json`",
205
+ "# exports — so a deleted module cannot leave a stale allowance behind. Surface can only fall.",
206
+ "#",
207
+ `# To record a deliberate change: ${pruneCommand(pkg)}`,
208
+ "# It refuses to RAISE anything, so new surface is a reviewed diff, never a silent one.",
209
+ "#",
210
+ "# What a count includes, what it excludes and why, are in the header of the measurement:",
211
+ "# cursedops/src/publicSurface.ts. Say nothing here that the entries below already say.",
212
+ ];
213
+
214
+ // ── the measurement ─────────────────────────────────────────────────────────────
215
+
216
+ const isRelative = (spec: string): boolean => spec.startsWith("./") || spec.startsWith("../");
217
+ const isWalkable = (file: string): boolean => /\.(m|c)?tsx?$/.test(file);
218
+
219
+ /** `./a/b` from `src/x/y.ts` → the first of `src/a/b.ts(x)`, `src/a/b/index.ts(x)`, … that exists. */
220
+ const resolveRelative = async (fromFile: string, spec: string): Promise<string | null> => {
221
+ const base = join(dirname(fromFile), spec);
222
+ // `./x.js` names `./x.ts` — the spelling Node ESM needs in the emitted file (task 269),
223
+ // which tsc copies verbatim from the source. Tried first so a `.js` never resolves to
224
+ // itself. This is the line two of the three forked copies each had to gain by hand.
225
+ const fromJs = /\.js$/.test(base) ? [base.replace(/\.js$/, ".ts"), base.replace(/\.js$/, ".tsx")] : [];
226
+ const candidates = [...fromJs, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`, base];
227
+ for (const c of candidates) {
228
+ if (await Bun.file(c).exists()) return c;
229
+ }
230
+ return null;
231
+ };
232
+
233
+ /**
234
+ * A bare `export * from 'pkg'` — resolved through node resolution so its symbols are
235
+ * counted rather than silently dropped. Throws when the package resolves to something
236
+ * with no parseable types, because a zero there would be a lie.
237
+ */
238
+ const resolveBare = (fromFile: string, spec: string): string => {
239
+ let resolved: string;
240
+ try {
241
+ resolved = Bun.resolveSync(spec, dirname(fromFile));
242
+ } catch (cause) {
243
+ throw new Error(
244
+ `${fromFile}: \`export * from '${spec}'\` re-exports a package that does not resolve, so its ` +
245
+ "symbols cannot be counted. Install it, or name the symbols — do not let the count lie.",
246
+ { cause },
247
+ );
248
+ }
249
+ if (!isWalkable(resolved)) {
250
+ throw new Error(
251
+ `${fromFile}: \`export * from '${spec}'\` resolves to ${resolved}, which this measurement cannot ` +
252
+ "parse. Point it at TypeScript, name the symbols, or teach cursedops/public-surface to read " +
253
+ "a declaration bundle — do not let the count lie.",
254
+ );
255
+ }
256
+ return resolved;
257
+ };
258
+
259
+ /** Every name a binding pattern introduces — `export const { a, b: [c] } = …` is three. */
260
+ const bindingNames = (name: ts.BindingName, out: Set<string>): void => {
261
+ if (ts.isIdentifier(name)) {
262
+ out.add(name.text);
263
+ return;
264
+ }
265
+ for (const el of name.elements) {
266
+ if (ts.isBindingElement(el)) bindingNames(el.name, out);
267
+ }
268
+ };
269
+
270
+ /**
271
+ * One measurement's parse cache. Barrels overlap heavily — `cursedbelt-server`'s 991
272
+ * counted symbols are 836 distinct names because `.` re-exports most leaf barrels — so
273
+ * every file is parsed once per run, not once per subpath that reaches it.
274
+ */
275
+ const walker = () => {
276
+ const parsed = new Map<string, ts.SourceFile>();
277
+ const sourceOf = async (file: string): Promise<ts.SourceFile> => {
278
+ const hit = parsed.get(file);
279
+ if (hit !== undefined) return hit;
280
+ // 🔴 Bun.file, NOT node:fs — a library's test preload can share one process with
281
+ // specs that virtualize node:fs, and a readFileSync scan then reads MOCK data and
282
+ // the measurement passes against files it never opened.
283
+ const source = ts.createSourceFile(file, await Bun.file(file).text(), ts.ScriptTarget.ES2022, true);
284
+ parsed.set(file, source);
285
+ return source;
286
+ };
287
+
288
+ /** Deduped exported names of `file`, following `export * from` chains. */
289
+ const exportedNames = async (file: string, seen = new Set<string>()): Promise<Set<string>> => {
290
+ const names = new Set<string>();
291
+ if (seen.has(file)) return names;
292
+ seen.add(file);
293
+
294
+ const source = await sourceOf(file);
295
+ const modifiersOf = (node: ts.Node): readonly ts.Modifier[] =>
296
+ ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : [];
297
+
298
+ for (const st of source.statements) {
299
+ if (ts.isExportDeclaration(st)) {
300
+ if (st.exportClause && ts.isNamedExports(st.exportClause)) {
301
+ for (const el of st.exportClause.elements) names.add(el.name.text);
302
+ } else if (st.exportClause && ts.isNamespaceExport(st.exportClause)) {
303
+ names.add(st.exportClause.name.text);
304
+ } else {
305
+ const spec = st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier) ? st.moduleSpecifier.text : null;
306
+ if (spec === null) continue;
307
+ let target: string | null;
308
+ if (isRelative(spec)) {
309
+ target = await resolveRelative(file, spec);
310
+ if (target === null) throw new Error(`${file}: \`export * from '${spec}'\` resolves to nothing.`);
311
+ } else {
312
+ target = resolveBare(file, spec);
313
+ }
314
+ for (const n of await exportedNames(target, seen)) names.add(n);
315
+ }
316
+ continue;
317
+ }
318
+ if (ts.isExportAssignment(st)) {
319
+ names.add("default");
320
+ continue;
321
+ }
322
+ const modifiers = modifiersOf(st);
323
+ if (!modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
324
+ // `export default function Foo()` is imported as `default`, not as `Foo`.
325
+ if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
326
+ names.add("default");
327
+ continue;
328
+ }
329
+ if (ts.isVariableStatement(st)) {
330
+ for (const d of st.declarationList.declarations) bindingNames(d.name, names);
331
+ } else if (
332
+ (ts.isFunctionDeclaration(st) ||
333
+ ts.isClassDeclaration(st) ||
334
+ ts.isInterfaceDeclaration(st) ||
335
+ ts.isTypeAliasDeclaration(st) ||
336
+ ts.isEnumDeclaration(st) ||
337
+ ts.isModuleDeclaration(st)) &&
338
+ st.name !== undefined
339
+ ) {
340
+ if (ts.isIdentifier(st.name) || ts.isStringLiteral(st.name)) names.add(st.name.text);
341
+ }
342
+ }
343
+ return names;
344
+ };
345
+ return exportedNames;
346
+ };
347
+
348
+ /** The `./src/**` or `./scripts/**` TypeScript file a subpath resolves to, or null when it is an asset. */
349
+ const entryOf = (root: string, value: unknown): string | null => {
350
+ if (typeof value === "string")
351
+ return (value.startsWith("./src/") || value.startsWith("./scripts/")) && isWalkable(value)
352
+ ? join(root, value.slice(2))
353
+ : null;
354
+ if (typeof value !== "object" || value === null) return null;
355
+ // `bun` and `source` point at TypeScript; `types` and `import` may point into `dist`,
356
+ // which is build output and may not exist. This order is what makes the measurement
357
+ // work on a clean checkout. Nested conditions (`./testing`) recurse.
358
+ const record = value as Record<string, unknown>;
359
+ for (const key of ["bun", "source", "types", "import", "default"]) {
360
+ if (key in record) {
361
+ const hit = entryOf(root, record[key]);
362
+ if (hit !== null) return hit;
363
+ }
364
+ }
365
+ for (const v of Object.values(record)) {
366
+ const hit = entryOf(root, v);
367
+ if (hit !== null) return hit;
368
+ }
369
+ return null;
370
+ };
371
+
372
+ /** Measure `pkg.exports` of the package at `root`. */
373
+ export const measureSurface = async (root: string, pkg: { exports?: Record<string, unknown> }): Promise<Surface> => {
374
+ const exportedNames = walker();
375
+ const counts = new Map<string, number>();
376
+ const assets: string[] = [];
377
+ for (const [subpath, value] of Object.entries(pkg.exports ?? {})) {
378
+ const entry = entryOf(root, value);
379
+ if (entry === null) {
380
+ assets.push(subpath);
381
+ continue;
382
+ }
383
+ counts.set(subpath, (await exportedNames(entry)).size);
384
+ }
385
+ return { counts, assets };
386
+ };
387
+
388
+ // ── peers ───────────────────────────────────────────────────────────────────────
389
+
390
+ /** Not a use of a peer: a test file, or anything under a `testing/` directory. */
391
+ const SKIP_FOR_PEERS = /(\.(test|spec)\.tsx?$)|((^|\/)testing\/)/;
392
+
393
+ export const peerProblems = async (root: string, pkg: { peerDependencies?: Record<string, string> }): Promise<string[]> => {
394
+ const peers = Object.entries(pkg.peerDependencies ?? {});
395
+ if (peers.length === 0) return [];
396
+
397
+ const files = [...new Bun.Glob("**/*.{ts,tsx}").scanSync({ cwd: join(root, "src") })]
398
+ .map((f) => `src/${f}`)
399
+ .filter((f) => !SKIP_FOR_PEERS.test(f));
400
+ const texts = await Promise.all(files.map((f) => Bun.file(join(root, f)).text()));
401
+ const haystack = texts.join("\n");
402
+
403
+ const problems: string[] = [];
404
+ for (const [name, range] of peers) {
405
+ const esc = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
406
+ const spec = `${esc}(?:/[^'"\`]*)?`;
407
+ const used = new RegExp(
408
+ `from\\s*['"\`]${spec}['"\`]` + // import x from 'p' / export … from 'p'
409
+ `|import\\s*\\(\\s*['"\`]${spec}['"\`]` + // await import('p'), typeof import('p')
410
+ `|require(?:Peer)?\\s*\\(\\s*['"\`]${spec}['"\`]` + // requirePeer('p', …)
411
+ `|@peer\\s+${esc}\\b`, // runtime-variable specifier, declared
412
+ ).test(haystack);
413
+ if (!used) {
414
+ problems.push(
415
+ `peerDependencies["${name}"] is imported by no non-test file under src/. ` +
416
+ "Remove it, or — if it is loaded through a runtime variable — declare it with an " +
417
+ "`@peer` pragma beside the loader.",
418
+ );
419
+ }
420
+ if (!/^[\d^~<>=*x\s|.-]/i.test(range) || /:|\/\//.test(range)) {
421
+ problems.push(
422
+ `peerDependencies["${name}"] = "${range}" is not a registry range. A URL, file:, link: or ` +
423
+ "git+ range cannot be reproduced by --frozen-lockfile and cannot be deduped by npm.",
424
+ );
425
+ }
426
+ }
427
+ return problems;
428
+ };
429
+
430
+ // ── the baseline ────────────────────────────────────────────────────────────────
431
+
432
+ export const parseBaseline = (text: string, fallbackHeader: string[] = defaultHeader({})): Baseline => {
433
+ const header = text.split("\n").filter((l) => l.startsWith("#"));
434
+ const counts = new Map<string, number>();
435
+ for (const line of text.split("\n")) {
436
+ if (!line.trim() || line.startsWith("#")) continue;
437
+ const m = line.match(/^\s*(\d+)\s+(\S.*)$/);
438
+ if (m?.[2] !== undefined) counts.set(m[2].trim(), Number(m[1]));
439
+ }
440
+ return { header: header.length > 0 ? header : fallbackHeader, counts };
441
+ };
442
+
443
+ export const readBaseline = async (file: string, fallbackHeader?: string[]): Promise<Baseline> => {
444
+ const f = Bun.file(file);
445
+ return parseBaseline((await f.exists()) ? await f.text() : "", fallbackHeader);
446
+ };
447
+
448
+ export const formatBaseline = (header: string[], counts: Map<string, number>): string =>
449
+ `${[...header, ...[...counts.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${n} ${k}`)].join("\n")}\n`;
450
+
451
+ export const compare = (surface: Surface, baseline: Baseline): Failures => {
452
+ const fresh: string[] = [];
453
+ const grown: string[] = [];
454
+ for (const [subpath, n] of surface.counts) {
455
+ const allowed = baseline.counts.get(subpath);
456
+ if (allowed === undefined) fresh.push(`${subpath} — NEW subpath, ${n} exported symbol(s)`);
457
+ else if (n > allowed) grown.push(`${subpath} — baseline allows ${allowed}, exports ${n} now`);
458
+ }
459
+ const stale = [...baseline.counts.entries()]
460
+ .filter(([subpath, n]) => (surface.counts.get(subpath) ?? 0) < n)
461
+ .map(([subpath, n]) =>
462
+ surface.counts.has(subpath)
463
+ ? `${subpath} — baseline says ${n}, exports ${surface.counts.get(subpath)}`
464
+ : `${subpath} — baseline says ${n}, the subpath is gone`,
465
+ );
466
+ return { fresh, grown, stale, peers: [] };
467
+ };
468
+
469
+ /** Measure the package at `options.root` and compare it with its baseline. */
470
+ export const run = async (options: SurfaceOptions): Promise<Failures & { surface: Surface; baseline: Baseline }> => {
471
+ const pkg = await readPkg(options.root);
472
+ const surface = await measureSurface(options.root, pkg);
473
+ const baseline = await readBaseline(baselineOf(options), defaultHeader(pkg));
474
+ const failures = compare(surface, baseline);
475
+ if (options.peers) failures.peers = await peerProblems(options.root, pkg);
476
+ return { ...failures, surface, baseline };
477
+ };
478
+
479
+ // ── CLI ─────────────────────────────────────────────────────────────────────────
480
+
481
+ /**
482
+ * The CLI, as a function: prints what the three per-library scripts printed and returns
483
+ * the exit code — 0 clean (or seeded/pruned), 1 on any failure or refusal.
484
+ */
485
+ export const main = async (argv: readonly string[], options: SurfaceOptions): Promise<number> => {
486
+ const BASELINE = baselineOf(options);
487
+ const ROOT = options.root;
488
+ const pkg = await readPkg(ROOT);
489
+ const { fresh, grown, stale, peers, surface, baseline } = await run({ ...options, peers: options.peers || argv.includes("--peers") });
490
+ const total = [...surface.counts.values()].reduce((a, b) => a + b, 0);
491
+
492
+ // `--seed` exists once, to record the surface the ratchet starts from. It REFUSES over
493
+ // an existing baseline, so re-seeding means deleting the file first — two visible steps
494
+ // and a whole-file diff, rather than one command that quietly re-baselines whatever grew.
495
+ if (argv.includes("--seed")) {
496
+ if (baseline.counts.size > 0) {
497
+ console.error(`publicSurface --seed refuses: ${BASELINE} already records ${baseline.counts.size} subpath(s).`);
498
+ console.error("Lower it with --prune. Raising it is a hand edit in a commit that says why.");
499
+ return 1;
500
+ }
501
+ await Bun.write(BASELINE, formatBaseline(baseline.header, surface.counts));
502
+ console.log(`publicSurface: seeded ${surface.counts.size} subpath(s), ${total} exported symbol(s).`);
503
+ return 0;
504
+ }
505
+
506
+ if (argv.includes("--prune")) {
507
+ // 🔴 It may only LOWER. Growth is the thing this exists to make visible; a prune
508
+ // that could absorb it would be the disable switch.
509
+ if (fresh.length > 0 || grown.length > 0) {
510
+ console.error("publicSurface --prune refuses while the surface has GROWN — it may only lower the baseline.\n");
511
+ for (const v of [...fresh, ...grown]) console.error(` ${v}`);
512
+ console.error(
513
+ "\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",
514
+ );
515
+ return 1;
516
+ }
517
+ const lowered = new Map<string, number>();
518
+ for (const [subpath, n] of baseline.counts) {
519
+ const now = surface.counts.get(subpath);
520
+ if (now !== undefined) lowered.set(subpath, Math.min(n, now));
521
+ }
522
+ await Bun.write(BASELINE, formatBaseline(baseline.header, lowered));
523
+ console.log(
524
+ `publicSurface: baseline pruned to ${lowered.size} subpath(s), ` +
525
+ `${baseline.counts.size - lowered.size} removed, ${[...lowered.values()].reduce((a, b) => a + b, 0)} symbols.`,
526
+ );
527
+ return 0;
528
+ }
529
+
530
+ let bad = false;
531
+ const report = (label: string, lines: string[], advice: string): void => {
532
+ if (lines.length === 0) return;
533
+ bad = true;
534
+ console.error(`\n${lines.length} ${label}:\n`);
535
+ for (const l of lines) console.error(` ${l}`);
536
+ console.error(`\n${advice}\n`);
537
+ };
538
+ report(
539
+ "export subpath(s) the baseline does not know",
540
+ fresh,
541
+ `A new subpath is a permanent public promise. If it is genuinely wanted, add its line to\n${BASELINE} and say why in the commit message.`,
542
+ );
543
+ report("subpath(s) that gained exported symbols", grown, "A baseline entry is a ceiling. Raise it by hand, in a commit that says why.");
544
+ report(
545
+ "baseline entr(y|ies) that no longer match `exports`",
546
+ stale,
547
+ `Surface came off and the ratchet was not tightened. Run:\n cd ${ROOT} && ${pruneCommand(pkg)}`,
548
+ );
549
+ report("peerDependency problem(s)", peers, "A peer nothing imports is a peer nobody can explain.");
550
+ if (bad) return 1;
551
+
552
+ console.log(
553
+ `publicSurface: ${surface.counts.size} subpath(s), ${total} exported symbol(s), ` +
554
+ `${surface.assets.length} asset export(s) — none grown.`,
555
+ );
556
+ return 0;
557
+ };
558
+
559
+ if (import.meta.main) {
560
+ // The package is the one the command was run in — `bun run <script>` starts in the
561
+ // package root, and a walk up makes a subdirectory work too.
562
+ const root = findPackageRoot(process.cwd());
563
+ if (root === null) {
564
+ console.error(`public-surface: no package.json at or above ${process.cwd()}.`);
565
+ process.exit(1);
566
+ }
567
+ process.exit(await main(process.argv.slice(2), { root }));
568
+ }