cursedbelt 5.12.1 → 5.13.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.
@@ -17,6 +17,13 @@ interface Rule {
17
17
  export declare function walk(dir: string, out?: string[]): string[];
18
18
  export declare function setReimplementedNames(names: readonly string[]): void;
19
19
  export declare const RULES: Record<string, Rule>;
20
+ /**
21
+ * Rules that are about cursedbelt's OWN source and mean nothing in an app's.
22
+ * `control-height-tokens` reads a fixed list of the belt's control-class files.
23
+ */
24
+ export declare const BELT_INTERNAL_RULES: ReadonlySet<string>;
25
+ /** What `--all-app-rules` scans: every rule an app's source can break. */
26
+ export declare const APP_RULES: readonly string[];
20
27
  export declare const ALLOWED_FILE_CATEGORIES: Set<string>;
21
28
  export interface FileAllow {
22
29
  rules: Set<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AAuMA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB;;;sEAGkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AA66BD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAEpE;AA+mBD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAqBtC,CAAC;AASF,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAmGhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
1
+ {"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AA+MA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB;;;sEAGkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AA66BD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAEpE;AA+mBD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAqBtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAAuC,CAAC;AAE5F,0EAA0E;AAC1E,eAAO,MAAM,SAAS,EAAE,SAAS,MAAM,EAAwE,CAAC;AAShH,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAmGhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.12.1",
3
+ "version": "5.13.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -26,6 +26,21 @@ import { compileStylesheet } from './generateUtilityStyles';
26
26
  */
27
27
  const noRepoEnv = (dir: string) => ({ ...process.env, GIT_CEILING_DIRECTORIES: dirname(dir) });
28
28
 
29
+ /** `Bun.spawnSync`-shaped, kept async — bun 1.4.2 can lose a child's exit under `bun test --parallel`
30
+ * when the wait is synchronous; the async form fails at the test's timeout instead of hanging the worker. */
31
+ async function spawnAsync(
32
+ argv: string[],
33
+ opts: { cwd?: string; env?: Record<string, string> } = {},
34
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
35
+ const child = Bun.spawn(argv, { cwd: opts.cwd, env: opts.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
36
+ const [stdout, stderr, exitCode] = await Promise.all([
37
+ new Response(child.stdout).text(),
38
+ new Response(child.stderr).text(),
39
+ child.exited,
40
+ ]);
41
+ return { exitCode, stdout, stderr };
42
+ }
43
+
29
44
  const BELT = {
30
45
  union: `@layer utilities { .flex { display: flex } .rounded-full { border-radius: 9999px } .hover\\:bg-muted { &:hover { @media (hover: hover) { background: var(--muted) } } } .recharts-tip { opacity: .5; } }`,
31
46
  areas: {
@@ -89,23 +104,23 @@ describe('checkAreaStyles', () => {
89
104
  expect([...tokensInJs(`a="[&>*]:w-full p-2" b='x'`)]).toContain('[&>*]:w-full');
90
105
  });
91
106
 
92
- test('the CLI exits 2 on a directory with nothing to compare, 1 on a miss, 0 when complete', () => {
107
+ test('the CLI exits 2 on a directory with nothing to compare, 1 on a miss, 0 when complete', async () => {
93
108
  const dir = mkdtempSync(join(tmpdir(), 'belt-area-check-'));
94
109
  try {
95
110
  const run = () =>
96
- Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
97
- expect(run().exitCode).toBe(2);
111
+ spawnAsync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir) });
112
+ expect((await run()).exitCode).toBe(2);
98
113
  const { union } = readBeltSheets();
99
114
  // `truncate` is a core utility in every build of the union.
100
115
  expect(classesInCss(union).has('truncate')).toBe(true);
101
116
  mkdirSync(join(dir, 'assets'));
102
117
  writeFileSync(join(dir, 'assets', 'index.js'), 'const c="truncate";');
103
118
  writeFileSync(join(dir, 'assets', 'index.css'), '.app{color:red}');
104
- const miss = run();
119
+ const miss = await run();
105
120
  expect(miss.exitCode).toBe(1);
106
- expect(miss.stderr.toString()).toContain('truncate');
121
+ expect(miss.stderr).toContain('truncate');
107
122
  writeFileSync(join(dir, 'assets', 'index.css'), '.truncate{overflow:hidden}');
108
- expect(run().exitCode).toBe(0);
123
+ expect((await run()).exitCode).toBe(0);
109
124
  } finally {
110
125
  rmSync(dir, { recursive: true, force: true });
111
126
  }
@@ -239,15 +254,15 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
239
254
  expect(hasVariantPrefix('text-[color:var(--x)]')).toBe(false);
240
255
  });
241
256
 
242
- test('the CLI exits 1 on an out-of-order build even when every class is styled', () => {
257
+ test('the CLI exits 1 on an out-of-order build even when every class is styled', async () => {
243
258
  const dir = mkdtempSync(join(tmpdir(), 'belt-order-check-'));
244
259
  try {
245
260
  mkdirSync(join(dir, 'assets'));
246
261
  writeFileSync(join(dir, 'assets', 'index.js'), 'const c="truncate";');
247
262
  writeFileSync(join(dir, 'assets', 'index.css'), '@layer utilities{@media(min-width:40rem){.sm\\:flex{display:flex}}.truncate{overflow:hidden}.hidden{display:none}}');
248
- const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
263
+ const r = await spawnAsync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir) });
249
264
  expect(r.exitCode).toBe(1);
250
- expect(r.stderr.toString()).toContain('.hidden (later) overrides .sm:flex');
265
+ expect(r.stderr).toContain('.hidden (later) overrides .sm:flex');
251
266
  } finally {
252
267
  rmSync(dir, { recursive: true, force: true });
253
268
  }
@@ -301,16 +316,16 @@ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no bui
301
316
  expect([...variablesDeclared('@property --tw-x{syntax:"*"}.a{--b:1;color:var(--c)}')].sort()).toEqual(['--b', '--tw-x']);
302
317
  });
303
318
 
304
- test('the CLI exits 1 on a build that reads an undeclared theme variable, naming it', () => {
319
+ test('the CLI exits 1 on a build that reads an undeclared theme variable, naming it', async () => {
305
320
  const dir = mkdtempSync(join(tmpdir(), 'belt-theme-var-check-'));
306
321
  try {
307
322
  mkdirSync(join(dir, 'assets'));
308
323
  writeFileSync(join(dir, 'assets', 'index.js'), `const c="truncate";${STATION_JS}`);
309
324
  writeFileSync(join(dir, 'assets', 'index.css'), '@layer theme{:root{--color-muted:var(--muted);--color-accent:var(--accent)}}@layer utilities{.truncate{overflow:hidden}}');
310
- const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
325
+ const r = await spawnAsync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir) });
311
326
  expect(r.exitCode).toBe(1);
312
- expect(r.stderr.toString()).toContain('--color-card');
313
- expect(r.stderr.toString()).not.toContain('--color-muted');
327
+ expect(r.stderr).toContain('--color-card');
328
+ expect(r.stderr).not.toContain('--color-muted');
314
329
  } finally {
315
330
  rmSync(dir, { recursive: true, force: true });
316
331
  }
@@ -319,7 +334,7 @@ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no bui
319
334
 
320
335
  describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global gitignore (5.3.1, family 0a298a5)', () => {
321
336
  /** A git repo whose "machine-global" excludes file is a temp file — the real ~/.gitignore_global is never read. */
322
- function fixture() {
337
+ async function fixture() {
323
338
  const base = mkdtempSync(join(tmpdir(), 'belt-scan-leak-'));
324
339
  const repo = join(base, 'app');
325
340
  mkdirSync(repo);
@@ -328,7 +343,7 @@ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global
328
343
  const gitconfig = join(base, 'gitconfig');
329
344
  writeFileSync(gitconfig, `[core]\n\texcludesFile = ${globalIgnore}\n`);
330
345
  const env = { ...process.env, GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1' };
331
- expect(Bun.spawnSync(['git', 'init', '-q', repo], { env }).exitCode).toBe(0);
346
+ expect((await spawnAsync(['git', 'init', '-q', repo], { env })).exitCode).toBe(0);
332
347
  writeFileSync(join(repo, '.gitignore'), 'dist/\n');
333
348
  mkdirSync(join(repo, 'src'));
334
349
  writeFileSync(join(repo, 'src', 'App.tsx'), 'export const A = () => <div className="flex" />;');
@@ -346,8 +361,8 @@ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global
346
361
  return { base, repo, env, globalIgnore };
347
362
  }
348
363
 
349
- test('RED: each globally-ignored TEXT path is named with the file and pattern that matched', () => {
350
- const { base, repo, env, globalIgnore } = fixture();
364
+ test('RED: each globally-ignored TEXT path is named with the file and pattern that matched', async () => {
365
+ const { base, repo, env, globalIgnore } = await fixture();
351
366
  try {
352
367
  const leaks = scanLeaks(repo, env);
353
368
  expect(leaks?.map((l) => l.path)).toEqual(['.agent.noindex/', 'src/probe.agent.tsx']);
@@ -362,8 +377,8 @@ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global
362
377
  }
363
378
  });
364
379
 
365
- test("GREEN once the repo's OWN .gitignore names them — the global file still matches, but no longer decides", () => {
366
- const { base, repo, env } = fixture();
380
+ test("GREEN once the repo's OWN .gitignore names them — the global file still matches, but no longer decides", async () => {
381
+ const { base, repo, env } = await fixture();
367
382
  try {
368
383
  writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
369
384
  expect(scanLeaks(repo, env)).toEqual([]);
@@ -372,8 +387,8 @@ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global
372
387
  }
373
388
  });
374
389
 
375
- test('.git/info/exclude is not the repo\'s .gitignore either — RED', () => {
376
- const { base, repo, env } = fixture();
390
+ test('.git/info/exclude is not the repo\'s .gitignore either — RED', async () => {
391
+ const { base, repo, env } = await fixture();
377
392
  try {
378
393
  writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
379
394
  writeFileSync(join(repo, 'notes.md'), 'bg-primary');
@@ -384,8 +399,8 @@ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global
384
399
  }
385
400
  });
386
401
 
387
- test('an app root that is itself git-ignored cannot be measured — it throws (the CLI exits 2)', () => {
388
- const { base, repo, env } = fixture();
402
+ test('an app root that is itself git-ignored cannot be measured — it throws (the CLI exits 2)', async () => {
403
+ const { base, repo, env } = await fixture();
389
404
  try {
390
405
  expect(() => scanLeaks(join(repo, '.agent.noindex'), env)).toThrow(/itself git-ignored/);
391
406
  } finally {
@@ -402,27 +417,25 @@ describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global
402
417
  }
403
418
  });
404
419
 
405
- test('the CLI exits 1 on a leak even when every class is styled, and 0 once the repo ignores it', () => {
406
- const { base, repo, env } = fixture();
420
+ test('the CLI exits 1 on a leak even when every class is styled, and 0 once the repo ignores it', async () => {
421
+ const { base, repo, env } = await fixture();
407
422
  try {
408
423
  writeFileSync(join(repo, 'dist', 'index.css'), '.truncate{overflow:hidden}');
409
424
  const run = () =>
410
- Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), 'dist'], { cwd: repo, env, stdout: 'pipe', stderr: 'pipe' });
411
- const red = run();
425
+ spawnAsync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), 'dist'], { cwd: repo, env });
426
+ const red = await run();
412
427
  expect(red.exitCode).toBe(1);
413
- expect(red.stderr.toString()).toContain('.agent.noindex/');
414
- expect(red.stderr.toString()).toContain('src/probe.agent.tsx');
428
+ expect(red.stderr).toContain('.agent.noindex/');
429
+ expect(red.stderr).toContain('src/probe.agent.tsx');
415
430
  // --root reads the same repo from anywhere.
416
- const elsewhere = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), join(repo, 'dist'), '--root', repo], {
431
+ const elsewhere = await spawnAsync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), join(repo, 'dist'), '--root', repo], {
417
432
  cwd: base,
418
433
  env,
419
- stdout: 'pipe',
420
- stderr: 'pipe',
421
434
  });
422
435
  expect(elsewhere.exitCode).toBe(1);
423
436
  writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
424
- const green = run();
425
- expect(green.stderr.toString()).toBe('');
437
+ const green = await run();
438
+ expect(green.stderr).toBe('');
426
439
  expect(green.exitCode).toBe(0);
427
440
  } finally {
428
441
  rmSync(base, { recursive: true, force: true });
@@ -8,6 +8,8 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { describe, expect, test } from 'bun:test';
10
10
  import {
11
+ APP_RULES,
12
+ BELT_INTERNAL_RULES,
11
13
  beltExportedComponentNames,
12
14
  parseFileAllows,
13
15
  RULES,
@@ -261,15 +263,15 @@ describe('no-raw-inputs', () => {
261
263
  expect(check('no-raw-inputs', source)).toHaveLength(0);
262
264
  });
263
265
 
264
- test('a file-scoped allowance does not excuse it, and the run fails', () => {
265
- withFixture(
266
+ test('a file-scoped allowance does not excuse it, and the run fails', async () => {
267
+ await withFixture(
266
268
  {
267
269
  'src/Choice.tsx':
268
270
  '// guardrails-file-allow no-raw-inputs: embeddable-primitive — pretend this is a standalone widget\n' +
269
271
  'export const C = () => <input type="radio" className="sr-only" />;\n',
270
272
  },
271
- (dir) => {
272
- const { code, report } = runCli(dir, ['no-raw-inputs', 'src']);
273
+ async (dir) => {
274
+ const { code, report } = await runCli(dir, ['no-raw-inputs', 'src']);
273
275
  expect(code).toBe(1);
274
276
  expect(report).toContain('Choice.tsx:2');
275
277
  expect(report).toContain('No opt-out');
@@ -277,14 +279,14 @@ describe('no-raw-inputs', () => {
277
279
  );
278
280
  });
279
281
 
280
- test('nor does a baseline row that counts it', () => {
281
- withFixture(
282
+ test('nor does a baseline row that counts it', async () => {
283
+ await withFixture(
282
284
  {
283
285
  'src/Choice.tsx': 'export const C = () => <input type="radio" className="sr-only" />;\n',
284
286
  'guardrails-baseline.json': JSON.stringify({ 'no-raw-inputs': 1 }),
285
287
  },
286
- (dir) => {
287
- const { code, report } = runCli(dir, ['no-raw-inputs', '--baseline', 'guardrails-baseline.json', 'src']);
288
+ async (dir) => {
289
+ const { code, report } = await runCli(dir, ['no-raw-inputs', '--baseline', 'guardrails-baseline.json', 'src']);
288
290
  expect(code).toBe(1);
289
291
  expect(report).toContain('No opt-out');
290
292
  },
@@ -1024,7 +1026,7 @@ describe('no-fast-tooltip', () => {
1024
1026
 
1025
1027
  const CLI = join(import.meta.dir, 'guardrailsEnforce.ts');
1026
1028
 
1027
- function withFixture(files: Record<string, string>, body: (dir: string) => void): void {
1029
+ async function withFixture(files: Record<string, string>, body: (dir: string) => void | Promise<void>): Promise<void> {
1028
1030
  const root = mkdtempSync(join(tmpdir(), 'guardrails-cli-'));
1029
1031
  try {
1030
1032
  for (const [rel, contents] of Object.entries(files)) {
@@ -1032,15 +1034,20 @@ function withFixture(files: Record<string, string>, body: (dir: string) => void)
1032
1034
  mkdirSync(join(full, '..'), { recursive: true });
1033
1035
  writeFileSync(full, contents);
1034
1036
  }
1035
- body(root);
1037
+ await body(root);
1036
1038
  } finally {
1037
1039
  rmSync(root, { recursive: true, force: true });
1038
1040
  }
1039
1041
  }
1040
1042
 
1041
- function runCli(cwd: string, args: string[]): { code: number; report: string } {
1042
- const run = Bun.spawnSync([process.execPath, CLI, ...args], { cwd });
1043
- return { code: run.exitCode, report: `${run.stdout.toString()}${run.stderr.toString()}` };
1043
+ async function runCli(cwd: string, args: string[]): Promise<{ code: number; report: string }> {
1044
+ const child = Bun.spawn([process.execPath, CLI, ...args], { cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
1045
+ const [stdout, stderr, code] = await Promise.all([
1046
+ new Response(child.stdout).text(),
1047
+ new Response(child.stderr).text(),
1048
+ child.exited,
1049
+ ]);
1050
+ return { code, report: `${stdout}${stderr}` };
1044
1051
  }
1045
1052
 
1046
1053
  /** Two hex colours and one raw <input>: counts a baseline row can be wrong about. */
@@ -1052,10 +1059,49 @@ const FIXTURE = {
1052
1059
  const rowsFor = (colors: number, inputs: number) =>
1053
1060
  JSON.stringify({ 'no-hardcoded-colors': colors, 'no-raw-inputs': inputs });
1054
1061
 
1062
+ describe('--all-app-rules (task 2219: a new belt rule reaches every app by itself)', () => {
1063
+ /** A baseline with a zero row for every app rule, then `overrides` on top. */
1064
+ const allRows = (overrides: Record<string, number | undefined> = {}) => {
1065
+ const rows: Record<string, number> = Object.fromEntries(APP_RULES.map((name) => [name, 0]));
1066
+ rows['no-hardcoded-colors'] = 2;
1067
+ rows['no-raw-inputs'] = 1;
1068
+ for (const [name, count] of Object.entries(overrides)) {
1069
+ if (count === undefined) delete rows[name];
1070
+ else rows[name] = count;
1071
+ }
1072
+ return JSON.stringify(rows);
1073
+ };
1074
+
1075
+ test('scans every rule in RULES except the belt-internal ones', () => {
1076
+ expect(APP_RULES).toEqual(Object.keys(RULES).filter((name) => !BELT_INTERNAL_RULES.has(name)));
1077
+ expect(APP_RULES).toContain('no-raw-lazy');
1078
+ expect(APP_RULES).not.toContain('control-height-tokens');
1079
+ });
1080
+
1081
+ test('passes when the baseline has a row for every app rule', async () => {
1082
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': allRows() }, async (dir) => {
1083
+ const { code, report } = await runCli(dir, ['--all-app-rules', '--baseline', 'scripts/baseline.json', 'src']);
1084
+ expect(report).toContain('✓ no-raw-lazy: 0 violation(s)');
1085
+ expect(report).not.toContain('control-height-tokens');
1086
+ expect(code).toBe(0);
1087
+ });
1088
+ });
1089
+
1090
+ test('🔴 a baseline missing a row for a rule the belt added FAILS, naming the rule', async () => {
1091
+ // The whole point: bump the belt, get a new rule, and the gate says so until the app
1092
+ // records its count — rather than the rule never running there at all.
1093
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': allRows({ 'no-raw-lazy': undefined }) }, async (dir) => {
1094
+ const { code, report } = await runCli(dir, ['--all-app-rules', '--baseline', 'scripts/baseline.json', 'src']);
1095
+ expect(code).toBe(2);
1096
+ expect(report).toContain('no row for rule(s) being scanned: no-raw-lazy');
1097
+ });
1098
+ });
1099
+ });
1100
+
1055
1101
  describe('--baseline (a consuming repo\'s ratchet)', () => {
1056
- test('passes when every row equals the count that is actually there', () => {
1057
- withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 1) }, (dir) => {
1058
- const { code, report } = runCli(dir, [
1102
+ test('passes when every row equals the count that is actually there', async () => {
1103
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 1) }, async (dir) => {
1104
+ const { code, report } = await runCli(dir, [
1059
1105
  'no-hardcoded-colors,no-raw-inputs',
1060
1106
  '--baseline',
1061
1107
  'scripts/baseline.json',
@@ -1067,11 +1113,11 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1067
1113
  });
1068
1114
  });
1069
1115
 
1070
- test('🔴 a row ABOVE the real count FAILS, printing the number to paste', () => {
1116
+ test('🔴 a row ABOVE the real count FAILS, printing the number to paste', async () => {
1071
1117
  // The half a one-sided ratchet gets wrong: fix a violation, leave the row where it
1072
1118
  // was, and the slack is available for the next one to grow back into silently.
1073
- withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(9, 1) }, (dir) => {
1074
- const { code, report } = runCli(dir, [
1119
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(9, 1) }, async (dir) => {
1120
+ const { code, report } = await runCli(dir, [
1075
1121
  'no-hardcoded-colors,no-raw-inputs',
1076
1122
  '--baseline',
1077
1123
  'scripts/baseline.json',
@@ -1083,9 +1129,9 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1083
1129
  });
1084
1130
  });
1085
1131
 
1086
- test('a count above its row fails, the way it always did', () => {
1087
- withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 0) }, (dir) => {
1088
- const { code, report } = runCli(dir, [
1132
+ test('a count above its row fails, the way it always did', async () => {
1133
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 0) }, async (dir) => {
1134
+ const { code, report } = await runCli(dir, [
1089
1135
  'no-hardcoded-colors,no-raw-inputs',
1090
1136
  '--baseline',
1091
1137
  'scripts/baseline.json',
@@ -1096,20 +1142,20 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1096
1142
  });
1097
1143
  });
1098
1144
 
1099
- test('a row for a rule nobody scans is refused — coverage that is not there', () => {
1100
- withFixture(
1145
+ test('a row for a rule nobody scans is refused — coverage that is not there', async () => {
1146
+ await withFixture(
1101
1147
  { ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2, 'no-raw-zindex': 0 }) },
1102
- (dir) => {
1103
- const { code, report } = runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
1148
+ async (dir) => {
1149
+ const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
1104
1150
  expect(code).toBe(2);
1105
1151
  expect(report).toContain('row(s) for rules nobody scans: no-raw-zindex');
1106
1152
  },
1107
1153
  );
1108
1154
  });
1109
1155
 
1110
- test('a scanned rule with no row is refused — a silent 0 demands the whole pile at once', () => {
1111
- withFixture({ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2 }) }, (dir) => {
1112
- const { code, report } = runCli(dir, [
1156
+ test('a scanned rule with no row is refused — a silent 0 demands the whole pile at once', async () => {
1157
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2 }) }, async (dir) => {
1158
+ const { code, report } = await runCli(dir, [
1113
1159
  'no-hardcoded-colors,no-raw-inputs',
1114
1160
  '--baseline',
1115
1161
  'scripts/baseline.json',
@@ -1120,34 +1166,34 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1120
1166
  });
1121
1167
  });
1122
1168
 
1123
- test('"$comment" carries the why; any OTHER non-number is a typo in a row', () => {
1124
- withFixture(
1169
+ test('"$comment" carries the why; any OTHER non-number is a typo in a row', async () => {
1170
+ await withFixture(
1125
1171
  {
1126
1172
  ...FIXTURE,
1127
1173
  'scripts/baseline.json': JSON.stringify({ $comment: ['the why', 'over several lines'], 'no-hardcoded-colors': 2 }),
1128
1174
  },
1129
- (dir) => {
1130
- expect(runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']).code).toBe(0);
1175
+ async (dir) => {
1176
+ expect((await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src'])).code).toBe(0);
1131
1177
  },
1132
1178
  );
1133
- withFixture({ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': '2' }) }, (dir) => {
1134
- const { code, report } = runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
1179
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': '2' }) }, async (dir) => {
1180
+ const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
1135
1181
  expect(code).toBe(2);
1136
1182
  expect(report).toContain('"no-hardcoded-colors" is "2", not a violation count');
1137
1183
  });
1138
1184
  });
1139
1185
 
1140
- test('a missing baseline file stops the run rather than defaulting every row to 0', () => {
1141
- withFixture(FIXTURE, (dir) => {
1142
- const { code, report } = runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/nope.json', 'src']);
1186
+ test('a missing baseline file stops the run rather than defaulting every row to 0', async () => {
1187
+ await withFixture(FIXTURE, async (dir) => {
1188
+ const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/nope.json', 'src']);
1143
1189
  expect(code).toBe(2);
1144
1190
  expect(report).toContain('no such file');
1145
1191
  });
1146
1192
  });
1147
1193
 
1148
- test('is not the packaged --scope ratchet, and says so rather than picking one', () => {
1149
- withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 1) }, (dir) => {
1150
- const { code, report } = runCli(dir, [
1194
+ test('is not the packaged --scope ratchet, and says so rather than picking one', async () => {
1195
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 1) }, async (dir) => {
1196
+ const { code, report } = await runCli(dir, [
1151
1197
  'no-hardcoded-colors',
1152
1198
  '--scope',
1153
1199
  'cursedbelt',
@@ -1160,12 +1206,12 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1160
1206
  });
1161
1207
  });
1162
1208
 
1163
- test('--scope stays ONE-sided: below its row passes and only suggests', () => {
1209
+ test('--scope stays ONE-sided: below its row passes and only suggests', async () => {
1164
1210
  // The packaged baseline lives inside node_modules, so a consumer cannot lower a row
1165
1211
  // in it. Failing them for a number only a cursedbelt release can change would be a
1166
1212
  // gate nobody can turn green — which is why only --baseline is two-sided.
1167
- withFixture(FIXTURE, (dir) => {
1168
- const { code, report } = runCli(dir, ['no-hardcoded-colors', '--scope', 'cursedbelt', 'src']);
1213
+ await withFixture(FIXTURE, async (dir) => {
1214
+ const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--scope', 'cursedbelt', 'src']);
1169
1215
  expect(code).toBe(1); // 2 found against the packaged cursedbelt row of 0
1170
1216
  expect(report).toContain('exceeds baseline of 0');
1171
1217
  });
@@ -1173,16 +1219,16 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1173
1219
  });
1174
1220
 
1175
1221
  describe('--reimplements-from-belt', () => {
1176
- test('flags a local component named after one this package exports', () => {
1222
+ test('flags a local component named after one this package exports', async () => {
1177
1223
  // Read off this package's own react index files, so the list is the truth of the
1178
1224
  // version an app installed rather than a copy it maintains.
1179
- withFixture(
1225
+ await withFixture(
1180
1226
  {
1181
1227
  'src/Button.tsx': 'export function Button() { return null; }\n',
1182
1228
  'scripts/baseline.json': JSON.stringify({ 'no-local-reimplementation': 0 }),
1183
1229
  },
1184
- (dir) => {
1185
- const { code, report } = runCli(dir, [
1230
+ async (dir) => {
1231
+ const { code, report } = await runCli(dir, [
1186
1232
  'no-local-reimplementation',
1187
1233
  '--baseline',
1188
1234
  'scripts/baseline.json',
@@ -1216,7 +1262,7 @@ describe('--reimplements-from-belt', () => {
1216
1262
  });
1217
1263
 
1218
1264
  describe('--exclude', () => {
1219
- test('drops build output that lives under a source root', () => {
1265
+ test('drops build output that lives under a source root', async () => {
1220
1266
  // desk's `src/generated/assets.ts` is the compiled browser app inlined as a module:
1221
1267
  // 282 double-counted violations that move with the bundle, reddening a two-sided
1222
1268
  // ratchet over work nobody did.
@@ -1225,11 +1271,11 @@ describe('--exclude', () => {
1225
1271
  'src/generated/assets.css': '.x { color: #123456; }\n.y { color: #654321; }\n',
1226
1272
  'scripts/baseline.json': rowsFor(2, 1),
1227
1273
  };
1228
- withFixture(files, (dir) => {
1229
- const scanned = runCli(dir, ['no-hardcoded-colors,no-raw-inputs', '--baseline', 'scripts/baseline.json', 'src']);
1274
+ await withFixture(files, async (dir) => {
1275
+ const scanned = await runCli(dir, ['no-hardcoded-colors,no-raw-inputs', '--baseline', 'scripts/baseline.json', 'src']);
1230
1276
  expect(scanned.code).toBe(1); // 4 colours, not 2 — the artifact counted as source
1231
1277
 
1232
- const { code, report } = runCli(dir, [
1278
+ const { code, report } = await runCli(dir, [
1233
1279
  'no-hardcoded-colors,no-raw-inputs',
1234
1280
  '--baseline',
1235
1281
  'scripts/baseline.json',
@@ -1242,9 +1288,9 @@ describe('--exclude', () => {
1242
1288
  });
1243
1289
  });
1244
1290
 
1245
- test('an exclusion matching nothing is refused, never read as handled', () => {
1246
- withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 1) }, (dir) => {
1247
- const { code, report } = runCli(dir, [
1291
+ test('an exclusion matching nothing is refused, never read as handled', async () => {
1292
+ await withFixture({ ...FIXTURE, 'scripts/baseline.json': rowsFor(2, 1) }, async (dir) => {
1293
+ const { code, report } = await runCli(dir, [
1248
1294
  'no-hardcoded-colors,no-raw-inputs',
1249
1295
  '--baseline',
1250
1296
  'scripts/baseline.json',
@@ -8,7 +8,7 @@
8
8
  // invoked from each package's `lint`/`done` script, exiting non-zero on violation).
9
9
  //
10
10
  // Usage:
11
- // bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
11
+ // bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...] | --all-app-rules
12
12
  // [--scope <name> | --baseline <path>]
13
13
  // [--reimplements <Name,Name,…> | --reimplements-from-belt]
14
14
  // [--exclude <path>[,<path>…]] [root...]
@@ -19,9 +19,17 @@
19
19
  //
20
20
  // A CONSUMING APP uses the other three instead, and needs no wrapper script of its own:
21
21
  //
22
- // "guardrails": "bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts <rules>
22
+ // "guardrails": "bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts --all-app-rules
23
23
  // --baseline scripts/guardrails-baseline.json --reimplements-from-belt src"
24
24
  //
25
+ // --all-app-rules in place of a rule list: every rule in `RULES` except the
26
+ // belt-internal ones (`BELT_INTERNAL_RULES`). Task 2219,
27
+ // 2026-09-27: each app used to NAME its rules, so a rule the
28
+ // belt added reached only the apps somebody remembered to
29
+ // edit — `no-raw-lazy` (5.12.0) reached nine of eleven. With
30
+ // this flag a belt bump that adds a rule reds the app's gate
31
+ // (its two-sided `--baseline` has no row for it) until the app
32
+ // records the row: adoption is a forced, visible step.
25
33
  // --baseline <path> a flat `{ "<rule>": <count> }` ratchet in the CALLER's
26
34
  // repo, TWO-SIDED — exceeding a row fails, and so does
27
35
  // falling below one, which prints the number to paste.
@@ -1832,6 +1840,15 @@ export const RULES: Record<string, Rule> = {
1832
1840
  [noRawLazy.name]: noRawLazy,
1833
1841
  };
1834
1842
 
1843
+ /**
1844
+ * Rules that are about cursedbelt's OWN source and mean nothing in an app's.
1845
+ * `control-height-tokens` reads a fixed list of the belt's control-class files.
1846
+ */
1847
+ export const BELT_INTERNAL_RULES: ReadonlySet<string> = new Set([controlHeightTokens.name]);
1848
+
1849
+ /** What `--all-app-rules` scans: every rule an app's source can break. */
1850
+ export const APP_RULES: readonly string[] = Object.keys(RULES).filter((name) => !BELT_INTERNAL_RULES.has(name));
1851
+
1835
1852
  // ── File-scoped allowances ──────────────────────────────────────────────────────
1836
1853
  // `guardrails-file-allow <rule>[,<rule>...]: <category> — <reason>` — a deliberate,
1837
1854
  // reviewable, header-only, category-tagged exemption for the rare file whose whole
@@ -2023,11 +2040,11 @@ export function beltExportedComponentNames(reactRoot: string = BELT_REACT_ROOT):
2023
2040
 
2024
2041
  function main() {
2025
2042
  const [ruleArg, ...rest] = process.argv.slice(2);
2026
- const ruleNames = ruleArg ? ruleArg.split(",") : [];
2043
+ const ruleNames = ruleArg === "--all-app-rules" ? [...APP_RULES] : ruleArg ? ruleArg.split(",") : [];
2027
2044
  const rules = ruleNames.map((n) => RULES[n]).filter((r): r is Rule => Boolean(r));
2028
2045
  if (ruleNames.length === 0 || rules.length !== ruleNames.length) {
2029
2046
  console.error(
2030
- `Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name> | --baseline <path>]\n` +
2047
+ `Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] | --all-app-rules [--scope <name> | --baseline <path>]\n` +
2031
2048
  ` [--reimplements <Name,…> | --reimplements-from-belt] [--exclude <path>[,<path>…]] [root...]\n` +
2032
2049
  `Known rules: ${Object.keys(RULES).join(", ")}`,
2033
2050
  );
package/scripts/paths.ts CHANGED
@@ -38,7 +38,7 @@ import { dirname, join, resolve } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
 
40
40
  /** The generation's tools this repo runs over its own tree, in the order they should fail. */
41
- const TOOLS = ["check-paths.ts", "check-doc-citations.ts", "check-gate-graph.ts"];
41
+ const TOOLS = ["check-paths.ts", "check-doc-citations.ts", "check-gate-graph.ts", "check-test-spawn.ts"];
42
42
 
43
43
  /** How far up either walk looks before giving up — a bound, so a misuse is `null` in ms. */
44
44
  const MAX_HOPS = 16;
@@ -125,18 +125,21 @@ const STATIC_SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+
125
125
  * `.js` it emits — a stylesheet cannot import a package, so the CSS output is
126
126
  * irrelevant to the question and only the JS is read.
127
127
  */
128
- const staticExternalsOf = (entry: string): Set<string> => {
128
+ const staticExternalsOf = async (entry: string): Promise<Set<string>> => {
129
129
  const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-barrel-scan/${entry.replace(/[^a-z0-9]+/gi, '-')}`;
130
- const build = Bun.spawnSync(
130
+ const child = Bun.spawn(
131
131
  ['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir],
132
- { cwd: REPO, stdout: 'pipe', stderr: 'pipe' },
132
+ { cwd: REPO, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
133
133
  );
134
+ const [, stderr, exitCode] = await Promise.all([
135
+ new Response(child.stdout).text(),
136
+ new Response(child.stderr).text(),
137
+ child.exited,
138
+ ]);
134
139
  const emitted = [...new Bun.Glob('**/*.js').scanSync({ cwd: outdir, onlyFiles: true })];
135
140
  const bundle = emitted.map((f) => readFileSync(`${outdir}/${f}`, 'utf8')).join('\n');
136
- if (build.exitCode !== 0 || bundle.trim() === '') {
137
- throw new Error(
138
- `could not bundle ${entry} (exit ${build.exitCode}) — a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
139
- );
141
+ if (exitCode !== 0 || bundle.trim() === '') {
142
+ throw new Error(`could not bundle ${entry} (exit ${exitCode}) — a check that cannot measure is not a passing check:\n${stderr}`);
140
143
  }
141
144
  const found = new Set<string>();
142
145
  for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
@@ -152,8 +155,8 @@ const staticExternalsOf = (entry: string): Set<string> => {
152
155
  return found;
153
156
  };
154
157
 
155
- const staticOptionalPeersOf = (entry: string): string[] =>
156
- [...staticExternalsOf(entry)].filter((name) => OPTIONAL_PEERS.has(name)).sort();
158
+ const staticOptionalPeersOf = async (entry: string): Promise<string[]> =>
159
+ [...(await staticExternalsOf(entry))].filter((name) => OPTIONAL_PEERS.has(name)).sort();
157
160
 
158
161
  /**
159
162
  * 🔴 The INVERSE of everything above, and the half task 149 had to add (2026-09-15).
@@ -213,17 +216,19 @@ describe('barrels reach no optional peer statically', () => {
213
216
  });
214
217
 
215
218
  for (const target of NO_STATIC_OPTIONAL_PEERS) {
216
- it(`cursedbelt/${target.subpath.slice(2)} costs a consumer no optional peer to import`, () => {
219
+ it(`cursedbelt/${target.subpath.slice(2)} costs a consumer no optional peer to import`, async () => {
217
220
  expect(
218
- staticOptionalPeersOf(target.entry),
221
+ await staticOptionalPeersOf(target.entry),
219
222
  `importing cursedbelt/${target.subpath.slice(2)} statically pulls these OPTIONAL peers, so every consumer must install them for code it may never call. Either import the leaf module that has what you need instead of a sibling INDEX (that is how 'otplib' got in here twice), or make the dependency lazy: \`const { x } = await import('pkg')\` inside the function that needs it.`,
220
223
  ).toEqual([]);
221
224
  });
222
225
  }
223
226
  });
224
227
 
228
+ const reachedFromReactIndex = await staticExternalsOf('src/react/index.ts');
229
+
225
230
  describe('every runtime dependency is one the barrel actually needs', () => {
226
- const reached = staticExternalsOf('src/react/index.ts');
231
+ const reached = reachedFromReactIndex;
227
232
  const declared = Object.keys(
228
233
  (pkg as { dependencies?: Record<string, string> }).dependencies ?? {},
229
234
  );
@@ -23,7 +23,6 @@
23
23
  * to skip it. Ignored-vs-not is the property that actually regresses.
24
24
  */
25
25
  import { describe, expect, test } from 'bun:test'
26
- import { spawnSync } from 'node:child_process'
27
26
  import { existsSync } from 'node:fs'
28
27
  import { fileURLToPath } from 'node:url'
29
28
 
@@ -37,13 +36,18 @@ describe('bun.lock is part of the checkout', () => {
37
36
  ).toBe(true)
38
37
  })
39
38
 
40
- test('git is not told to ignore it', () => {
41
- const git = spawnSync('git', ['check-ignore', '-q', 'bun.lock'], { cwd: repoRoot })
42
- if (git.error) return // no git on this host: nothing to assert about ignore rules
39
+ test('git is not told to ignore it', async () => {
40
+ let status: number
41
+ try {
42
+ const child = Bun.spawn(['git', 'check-ignore', '-q', 'bun.lock'], { cwd: repoRoot, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
43
+ status = await child.exited
44
+ } catch {
45
+ return // no git on this host: nothing to assert about ignore rules
46
+ }
43
47
 
44
48
  // exit 0 = the path IS ignored; 1 = it is not; anything else = git could not tell us.
45
49
  expect(
46
- git.status,
50
+ status,
47
51
  'bun.lock is gitignored, so a fresh clone has no lockfile and `bun install --frozen-lockfile` cannot pass there. Delete the stanza from .gitignore rather than the assertion.',
48
52
  ).not.toBe(0)
49
53
  })
@@ -25,7 +25,6 @@
25
25
  * when `git` cannot answer — a check that silently degrades to green is worse than none.
26
26
  */
27
27
  import { describe, expect, test } from 'bun:test';
28
- import { spawnSync } from 'node:child_process';
29
28
  import { existsSync } from 'node:fs';
30
29
  import { fileURLToPath } from 'node:url';
31
30
  import pkg from '../package.json';
@@ -36,14 +35,15 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
36
35
  const shippedDirs = (): string[] =>
37
36
  ((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
38
37
 
39
- const git = (...args: string[]): { ok: boolean; out: string } => {
40
- const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
41
- return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
38
+ const git = async (...args: string[]): Promise<{ ok: boolean; out: string }> => {
39
+ const child = Bun.spawn(['git', '-C', repoRoot, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
40
+ const [out, , code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
41
+ return { ok: code === 0, out: out.trim() };
42
42
  };
43
43
 
44
44
  describe('every file inside the published surface is tracked', () => {
45
- test('git can answer at all — a degraded check must refuse, not pass', () => {
46
- expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
45
+ test('git can answer at all — a degraded check must refuse, not pass', async () => {
46
+ expect((await git('rev-parse', '--is-inside-work-tree')).ok, 'not a git checkout, so this check proves nothing').toBe(true);
47
47
  });
48
48
 
49
49
  test('package.json names a shippable directory to scan', () => {
@@ -52,10 +52,10 @@ describe('every file inside the published surface is tracked', () => {
52
52
  expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
53
53
  });
54
54
 
55
- test('no untracked file sits inside a directory `files` ships', () => {
55
+ test('no untracked file sits inside a directory `files` ships', async () => {
56
56
  // --others = untracked; --exclude-standard honours .gitignore, so deliberately
57
57
  // ignored paths (.agent.noindex, node_modules) are not offences.
58
- const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
58
+ const { ok, out } = await git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
59
59
  expect(ok, 'git ls-files failed').toBe(true);
60
60
  const untracked = out === '' ? [] : out.split('\n');
61
61
  expect(
@@ -28,7 +28,6 @@
28
28
  * ~90ms per project because `--showConfig` does no type checking at all.
29
29
  */
30
30
  import { describe, expect, it } from 'bun:test';
31
- import { spawnSync } from 'node:child_process';
32
31
  import { type Dirent, readdirSync } from 'node:fs';
33
32
  import { dirname, join, relative, resolve } from 'node:path';
34
33
 
@@ -78,13 +77,20 @@ interface Resolved {
78
77
  emits: boolean;
79
78
  }
80
79
 
81
- function resolveProject(path: string): Resolved {
82
- const r = spawnSync(process.execPath, ['x', 'tsc', '--showConfig', '-p', path], {
80
+ async function resolveProject(path: string): Promise<Resolved> {
81
+ const child = Bun.spawn([process.execPath, 'x', 'tsc', '--showConfig', '-p', path], {
83
82
  cwd: ROOT,
84
- encoding: 'utf8',
83
+ stdin: 'ignore',
84
+ stdout: 'pipe',
85
+ stderr: 'pipe',
85
86
  });
86
- expect(r.status, `tsc --showConfig failed for ${path}:\n${r.stderr}`).toBe(0);
87
- const options = (JSON.parse(r.stdout) as { compilerOptions?: Record<string, unknown> }).compilerOptions ?? {};
87
+ const [stdout, stderr, code] = await Promise.all([
88
+ new Response(child.stdout).text(),
89
+ new Response(child.stderr).text(),
90
+ child.exited,
91
+ ]);
92
+ expect(code, `tsc --showConfig failed for ${path}:\n${stderr}`).toBe(0);
93
+ const options = (JSON.parse(stdout) as { compilerOptions?: Record<string, unknown> }).compilerOptions ?? {};
88
94
  const info = options.tsBuildInfoFile;
89
95
  return {
90
96
  name: relative(ROOT, path),
@@ -94,7 +100,7 @@ function resolveProject(path: string): Resolved {
94
100
  };
95
101
  }
96
102
 
97
- const PROJECTS = projectPaths().map(resolveProject);
103
+ const PROJECTS = await Promise.all(projectPaths().map(resolveProject));
98
104
 
99
105
  describe('the typecheck caches', () => {
100
106
  it('finds every tsconfig in the repo', () => {
@@ -36,18 +36,24 @@ const packageOf = (specifier: string): string => {
36
36
  };
37
37
 
38
38
  /** Every package an entry's bundle names, static or dynamic, with all packages left external. */
39
- function packagesReachedBy(entry: string, label: string): Set<string> {
39
+ async function packagesReachedBy(entry: string, label: string): Promise<Set<string>> {
40
40
  const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-unpeered-scan-${process.pid}/${label}`;
41
41
  rmSync(outdir, { recursive: true, force: true });
42
- const build = Bun.spawnSync(['bun', 'build', entry, '--target=browser', '--packages=external', '--outdir', outdir], {
42
+ const child = Bun.spawn(['bun', 'build', entry, '--target=browser', '--packages=external', '--outdir', outdir], {
43
43
  cwd: REPO,
44
+ stdin: 'ignore',
44
45
  stdout: 'pipe',
45
46
  stderr: 'pipe',
46
47
  });
48
+ const [, stderr, exitCode] = await Promise.all([
49
+ new Response(child.stdout).text(),
50
+ new Response(child.stderr).text(),
51
+ child.exited,
52
+ ]);
47
53
  const emitted = [...new Bun.Glob('**/*.js').scanSync({ cwd: outdir, onlyFiles: true })];
48
54
  const bundle = emitted.map((f) => readFileSync(`${outdir}/${f}`, 'utf8')).join('\n');
49
- if (build.exitCode !== 0 || bundle.trim() === '') {
50
- throw new Error(`could not bundle ${entry} (exit ${build.exitCode}) — a check that cannot measure is not a passing check:\n${build.stderr.toString()}`);
55
+ if (exitCode !== 0 || bundle.trim() === '') {
56
+ throw new Error(`could not bundle ${entry} (exit ${exitCode}) — a check that cannot measure is not a passing check:\n${stderr}`);
51
57
  }
52
58
  const found = new Set<string>();
53
59
  for (const m of bundle.matchAll(ANY_SPECIFIER)) {
@@ -58,10 +64,10 @@ function packagesReachedBy(entry: string, label: string): Set<string> {
58
64
  }
59
65
 
60
66
  describe('unpeered packages stay off the cursedbelt/react barrel', () => {
61
- it('🔴 the barrel names no unpeered package, even lazily', () => {
67
+ it('🔴 the barrel names no unpeered package, even lazily', async () => {
62
68
  const source = (pkg.exports as Record<string, { source?: string }>)['./react']?.source;
63
69
  expect(source, 'package.json has no ./react source entry').toBeString();
64
- const reached = packagesReachedBy(`${REPO}${(source as string).slice(2)}`, 'barrel');
70
+ const reached = await packagesReachedBy(`${REPO}${(source as string).slice(2)}`, 'barrel');
65
71
  expect(reached.size, 'the scan found no packages at all — it is not measuring').toBeGreaterThan(0);
66
72
  expect(
67
73
  UNPEERED.filter((name) => reached.has(name)),
@@ -84,13 +90,13 @@ describe('unpeered packages stay off the cursedbelt/react barrel', () => {
84
90
  expect(UNPEERED.filter((n) => !named.has(n))).toEqual([]);
85
91
  });
86
92
 
87
- it('the scan sees a lazy import — 5.0.0 reproduced, so this check can go red', () => {
93
+ it('the scan sees a lazy import — 5.0.0 reproduced, so this check can go red', async () => {
88
94
  const dir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-unpeered-fixture-${process.pid}`;
89
95
  mkdirSync(dir, { recursive: true });
90
96
  const entry = `${dir}/lazy.ts`;
91
97
  writeFileSync(entry, "export const Picker = () => import('@emoji-mart/react');\nexport const zod = () => import('@hookform/resolvers/zod');\n");
92
98
  try {
93
- const reached = packagesReachedBy(entry, 'fixture');
99
+ const reached = await packagesReachedBy(entry, 'fixture');
94
100
  expect([...reached].sort()).toEqual(['@emoji-mart/react', '@hookform/resolvers']);
95
101
  } finally {
96
102
  rmSync(dir, { recursive: true, force: true });