cursedbelt 5.12.0 → 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.
@@ -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
  })
@@ -1,6 +1,7 @@
1
1
  import { Component, type ErrorInfo, type ReactNode, useEffect, useState } from 'react';
2
2
  import { Alert } from './Alert.js';
3
3
  import { Spinner } from './Spinner.js';
4
+ import { claimStaleChunkReload, staleChunkReloadedRecently } from '../lazy/staleChunk.js';
4
5
  import { ConnectionHealer, isUnreachableError, type ServerHealOptions } from './ConnectionHealer.js';
5
6
 
6
7
  /* ───────────────────────────────────────────────────────────────────────────
@@ -88,7 +89,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
88
89
  // visualizer, the owner's report of 2026-08-22), and `isUnreachableError` matches
89
90
  // the substring `failed to fetch`, so `autoHeal` would have claimed the server was
90
91
  // unreachable while it answered every request. `StaleDeployHealer` reloads exactly
91
- // once per tab per URL and explains itself if that does not help.
92
+ // once per tab per minute and explains itself if that does not help.
92
93
  if (isStaleChunkError(error)) return <StaleDeployHealer error={error} />;
93
94
  if (autoHeal && isUnreachableError(error))
94
95
  return <ConnectionHealer error={error} {...(autoHeal === true ? {} : autoHeal)} />;
@@ -129,7 +130,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
129
130
  * hashes, and the route loads. If the chunk is genuinely missing from the
130
131
  * deployed artifact rather than merely stale, reloading would loop — so the
131
132
  * attempt is recorded in `sessionStorage` and only ever made ONCE per tab per
132
- * URL. The second failure stops and says what is actually wrong, which is a
133
+ * minute (the memory `lazyWithReload` shares). The second failure stops and says what is actually wrong, which is a
133
134
  * broken deployment and a sentence the owner can act on.
134
135
  *
135
136
  * Since 2026-08-22 the origin answers a missing build asset with a 404 rather
@@ -157,27 +158,19 @@ export function isStaleChunkError(error: unknown): boolean {
157
158
  return STALE_CHUNK_PATTERNS.some((p) => message.includes(p));
158
159
  }
159
160
 
160
- /** One key per tab per URL — a reload that did not help must not be repeated. */
161
- const attemptKey = (): string =>
162
- `cwip.staleChunkReload:${typeof window === 'undefined' ? '' : window.location.pathname}`;
163
-
164
- /** Whether this tab has already tried reloading for this URL. Never throws. */
161
+ /**
162
+ * Whether this tab already reloaded for a stale chunk inside the last minute — and
163
+ * true when storage is unavailable, so a reload loop is impossible in the one
164
+ * environment where we cannot remember trying. Never throws.
165
+ *
166
+ * 🔴 The memory is `lazyWithReload`'s (`../lazy/staleChunk.ts`), shared on purpose.
167
+ * Until 2026-09-27 this was its own flag, once per tab per URL FOREVER: a tab left
168
+ * open across a SECOND deploy — a film playing for hours — got "could not be
169
+ * loaded" instead of a reload, and the two guards could add up to two reloads.
170
+ */
165
171
  export function staleChunkReloadAttempted(): boolean {
166
- try {
167
- return window.sessionStorage.getItem(attemptKey()) !== null;
168
- } catch {
169
- // Private mode / disabled storage: treat as "already tried" so a reload loop
170
- // is impossible in the one environment where we cannot remember trying.
171
- return true;
172
- }
173
- }
174
-
175
- function markStaleChunkReload(): void {
176
- try {
177
- window.sessionStorage.setItem(attemptKey(), '1');
178
- } catch {
179
- // Nothing to do — `staleChunkReloadAttempted` fails closed for the same reason.
180
- }
172
+ if (typeof window === 'undefined') return true;
173
+ return staleChunkReloadedRecently();
181
174
  }
182
175
 
183
176
  export interface StaleDeployHealerProps {
@@ -198,11 +191,10 @@ export const StaleDeployHealer = ({ error, onRecover }: StaleDeployHealerProps)
198
191
 
199
192
  useEffect(() => {
200
193
  if (typeof window === 'undefined') return;
201
- if (staleChunkReloadAttempted()) {
194
+ if (!claimStaleChunkReload()) {
202
195
  setExhausted(true);
203
196
  return;
204
197
  }
205
- markStaleChunkReload();
206
198
  // A tick of delay so the banner paints first — an instant reload on a slow
207
199
  // connection reads as a flicker with no explanation.
208
200
  const timer = setTimeout(() => {
@@ -1,5 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
2
  import { cleanup, render, screen, waitFor } from '@testing-library/react';
3
+ import { STALE_CHUNK_RELOADED_AT_KEY } from '../lazy/staleChunk.js';
3
4
  import { ErrorBoundary, isStaleChunkError, StaleDeployHealer } from './ErrorBoundary.js';
4
5
 
5
6
  afterEach(() => {
@@ -105,3 +106,33 @@ describe('StaleDeployHealer recovers exactly once', () => {
105
106
  expect(screen.getByText(/VisualizerView-9pEZSQqW\.js/)).toBeTruthy();
106
107
  });
107
108
  });
109
+
110
+ describe('StaleDeployHealer shares lazyWithReload\'s memory', () => {
111
+ /**
112
+ * 🔴 The old flag was once per tab per URL FOREVER, so a tab left open across a SECOND
113
+ * deploy — a film playing for hours — hit the dead end instead of reloading.
114
+ */
115
+ test('a tab open across two deploys heals both', async () => {
116
+ let recovered = 0;
117
+ const first = render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
118
+ await waitFor(() => expect(recovered).toBe(1), { timeout: 2000 });
119
+ first.unmount();
120
+
121
+ const twoHoursLater = Date.now() + 2 * 60 * 60_000;
122
+ const clock = spyOn(Date, 'now').mockImplementation(() => twoHoursLater);
123
+ try {
124
+ render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
125
+ await waitFor(() => expect(recovered).toBe(2), { timeout: 2000 });
126
+ } finally {
127
+ clock.mockRestore();
128
+ }
129
+ });
130
+
131
+ test('a reload lazyWithReload just made is not repeated by the boundary', async () => {
132
+ window.sessionStorage.setItem(STALE_CHUNK_RELOADED_AT_KEY, String(Date.now() - 1000));
133
+ let recovered = 0;
134
+ render(<StaleDeployHealer error={OWNERS_ERROR} onRecover={() => recovered++} />);
135
+ await waitFor(() => expect(screen.getByText(/could not be loaded/i)).toBeTruthy());
136
+ expect(recovered).toBe(0);
137
+ });
138
+ });
@@ -82,12 +82,31 @@ describe('a chunk a deploy removed', () => {
82
82
  expect(state.reloads).toBe(2);
83
83
  });
84
84
 
85
- test('with no storage at all it still reloads rather than failing', async () => {
85
+ /*
86
+ * 🔴 Fail CLOSED. With no memory, a chunk genuinely missing from the deployed build
87
+ * would reload forever; the error goes to the boundary instead, which explains it.
88
+ */
89
+ test('with no storage it never reloads — the error goes to the boundary', async () => {
86
90
  const { state } = fakeEnv(1_000_000);
87
91
  const env: StaleChunkEnv = { storage: null, reload: () => void state.reloads++, now: () => 5 };
88
- void reloadOnStaleChunk(gone, () => env)();
89
- await tick();
90
- expect(state.reloads).toBe(1);
92
+ await expect(reloadOnStaleChunk(gone, () => env)()).rejects.toThrow('dynamically imported');
93
+ expect(state.reloads).toBe(0);
94
+ });
95
+
96
+ test('storage that refuses a write is no memory either — no reload', async () => {
97
+ const { state } = fakeEnv(1_000_000);
98
+ const env: StaleChunkEnv = {
99
+ storage: {
100
+ getItem: () => null,
101
+ setItem: () => {
102
+ throw new DOMException('blocked', 'SecurityError');
103
+ },
104
+ },
105
+ reload: () => void state.reloads++,
106
+ now: () => 5,
107
+ };
108
+ await expect(reloadOnStaleChunk(gone, () => env)()).rejects.toThrow('dynamically imported');
109
+ expect(state.reloads).toBe(0);
91
110
  });
92
111
  });
93
112