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.
- package/dist/scripts/guardrailsEnforce.d.ts +7 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/checkAreaStyles.spec.ts +47 -34
- package/scripts/guardrailsEnforce.spec.ts +101 -55
- package/scripts/guardrailsEnforce.ts +21 -4
- package/scripts/paths.ts +1 -1
- package/src/barrelsReachNoOptionalPeer.spec.ts +17 -12
- package/src/lockfileIsTracked.spec.ts +9 -5
- package/src/shippedFilesAreTracked.spec.ts +8 -8
- package/src/typecheckCachesAreSeparate.spec.ts +13 -7
- package/src/unpeeredStaysOffTheBarrel.spec.ts +14 -8
|
@@ -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":";
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
313
|
-
expect(r.stderr
|
|
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(
|
|
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
|
-
|
|
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
|
|
414
|
-
expect(red.stderr
|
|
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 =
|
|
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
|
|
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
|
|
1043
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
83
|
+
stdin: 'ignore',
|
|
84
|
+
stdout: 'pipe',
|
|
85
|
+
stderr: 'pipe',
|
|
85
86
|
});
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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 (
|
|
50
|
-
throw new Error(`could not bundle ${entry} (exit ${
|
|
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 });
|