cursedbelt 5.3.0 → 5.3.1
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
5
|
import {
|
|
6
6
|
cascadeFaults,
|
|
7
7
|
checkAreaStyles,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
onlyInMergeTables,
|
|
13
13
|
readBeltSheets,
|
|
14
14
|
readThemeVariables,
|
|
15
|
+
scanLeaks,
|
|
15
16
|
themeVariablesIn,
|
|
16
17
|
tokensInJs,
|
|
17
18
|
undefinedThemeVariables,
|
|
@@ -19,6 +20,12 @@ import {
|
|
|
19
20
|
} from './checkAreaStyles';
|
|
20
21
|
import { compileStylesheet } from './generateUtilityStyles';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The CLI also runs the scan-leak check over its cwd. These fixtures live in a temp dir — which this
|
|
25
|
+
* repo's test preload puts under `.agent.noindex/` — so tell git the temp dir is in NO work tree.
|
|
26
|
+
*/
|
|
27
|
+
const noRepoEnv = (dir: string) => ({ ...process.env, GIT_CEILING_DIRECTORIES: dirname(dir) });
|
|
28
|
+
|
|
22
29
|
const BELT = {
|
|
23
30
|
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; } }`,
|
|
24
31
|
areas: {
|
|
@@ -86,7 +93,7 @@ describe('checkAreaStyles', () => {
|
|
|
86
93
|
const dir = mkdtempSync(join(tmpdir(), 'belt-area-check-'));
|
|
87
94
|
try {
|
|
88
95
|
const run = () =>
|
|
89
|
-
Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
|
|
96
|
+
Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
|
|
90
97
|
expect(run().exitCode).toBe(2);
|
|
91
98
|
const { union } = readBeltSheets();
|
|
92
99
|
// `truncate` is a core utility in every build of the union.
|
|
@@ -222,7 +229,7 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
|
|
|
222
229
|
mkdirSync(join(dir, 'assets'));
|
|
223
230
|
writeFileSync(join(dir, 'assets', 'index.js'), 'const c="truncate";');
|
|
224
231
|
writeFileSync(join(dir, 'assets', 'index.css'), '@layer utilities{@media(min-width:40rem){.sm\\:flex{display:flex}}.truncate{overflow:hidden}.hidden{display:none}}');
|
|
225
|
-
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
|
|
232
|
+
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
|
|
226
233
|
expect(r.exitCode).toBe(1);
|
|
227
234
|
expect(r.stderr.toString()).toContain('.hidden (later) overrides .sm:flex');
|
|
228
235
|
} finally {
|
|
@@ -284,7 +291,7 @@ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no bui
|
|
|
284
291
|
mkdirSync(join(dir, 'assets'));
|
|
285
292
|
writeFileSync(join(dir, 'assets', 'index.js'), `const c="truncate";${STATION_JS}`);
|
|
286
293
|
writeFileSync(join(dir, 'assets', 'index.css'), '@layer theme{:root{--color-muted:var(--muted);--color-accent:var(--accent)}}@layer utilities{.truncate{overflow:hidden}}');
|
|
287
|
-
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
|
|
294
|
+
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
|
|
288
295
|
expect(r.exitCode).toBe(1);
|
|
289
296
|
expect(r.stderr.toString()).toContain('--color-card');
|
|
290
297
|
expect(r.stderr.toString()).not.toContain('--color-muted');
|
|
@@ -293,3 +300,116 @@ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no bui
|
|
|
293
300
|
}
|
|
294
301
|
});
|
|
295
302
|
});
|
|
303
|
+
|
|
304
|
+
describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global gitignore (5.3.1, family 0a298a5)', () => {
|
|
305
|
+
/** A git repo whose "machine-global" excludes file is a temp file — the real ~/.gitignore_global is never read. */
|
|
306
|
+
function fixture() {
|
|
307
|
+
const base = mkdtempSync(join(tmpdir(), 'belt-scan-leak-'));
|
|
308
|
+
const repo = join(base, 'app');
|
|
309
|
+
mkdirSync(repo);
|
|
310
|
+
const globalIgnore = join(base, 'gitignore_global');
|
|
311
|
+
writeFileSync(globalIgnore, '.DS_Store\n.agent.noindex/\n*.agent.*\nnode_modules\n');
|
|
312
|
+
const gitconfig = join(base, 'gitconfig');
|
|
313
|
+
writeFileSync(gitconfig, `[core]\n\texcludesFile = ${globalIgnore}\n`);
|
|
314
|
+
const env = { ...process.env, GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1' };
|
|
315
|
+
expect(Bun.spawnSync(['git', 'init', '-q', repo], { env }).exitCode).toBe(0);
|
|
316
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n');
|
|
317
|
+
mkdirSync(join(repo, 'src'));
|
|
318
|
+
writeFileSync(join(repo, 'src', 'App.tsx'), 'export const A = () => <div className="flex" />;');
|
|
319
|
+
// The scratch that made family's "core only" build look complete.
|
|
320
|
+
mkdirSync(join(repo, '.agent.noindex'));
|
|
321
|
+
writeFileSync(join(repo, '.agent.noindex', 'compare-styles.agent.ts'), 'const c = "truncate rounded-full grid-cols-3";');
|
|
322
|
+
writeFileSync(join(repo, 'src', 'probe.agent.tsx'), '<p className="sr-only" />');
|
|
323
|
+
// Globally ignored but nothing an extractor reads: binary, and inside node_modules.
|
|
324
|
+
writeFileSync(join(repo, '.DS_Store'), Buffer.from([0, 0, 0, 1, 66, 117, 100, 49, 0]));
|
|
325
|
+
mkdirSync(join(repo, 'node_modules', 'x'), { recursive: true });
|
|
326
|
+
writeFileSync(join(repo, 'node_modules', 'x', 'index.js'), 'const c = "flex";');
|
|
327
|
+
// Ignored by the repo itself — Tailwind skips it too.
|
|
328
|
+
mkdirSync(join(repo, 'dist'));
|
|
329
|
+
writeFileSync(join(repo, 'dist', 'index.js'), 'const c="truncate";');
|
|
330
|
+
return { base, repo, env, globalIgnore };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
test('RED: each globally-ignored TEXT path is named with the file and pattern that matched', () => {
|
|
334
|
+
const { base, repo, env, globalIgnore } = fixture();
|
|
335
|
+
try {
|
|
336
|
+
const leaks = scanLeaks(repo, env);
|
|
337
|
+
expect(leaks?.map((l) => l.path)).toEqual(['.agent.noindex/', 'src/probe.agent.tsx']);
|
|
338
|
+
// `*.agent.*` (line 3) matches `.agent.noindex/` too, and the LAST match in a file decides.
|
|
339
|
+
expect(leaks?.[0]?.source).toBe(`${globalIgnore}:3`);
|
|
340
|
+
expect(leaks?.[0]?.textFiles).toBe(1);
|
|
341
|
+
expect(leaks?.[1]?.pattern).toBe('*.agent.*');
|
|
342
|
+
// From a SUBDIRECTORY root, only what is under it.
|
|
343
|
+
expect(scanLeaks(join(repo, 'src'), env)?.map((l) => l.path)).toEqual(['probe.agent.tsx']);
|
|
344
|
+
} finally {
|
|
345
|
+
rmSync(base, { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("GREEN once the repo's OWN .gitignore names them — the global file still matches, but no longer decides", () => {
|
|
350
|
+
const { base, repo, env } = fixture();
|
|
351
|
+
try {
|
|
352
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
|
|
353
|
+
expect(scanLeaks(repo, env)).toEqual([]);
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(base, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('.git/info/exclude is not the repo\'s .gitignore either — RED', () => {
|
|
360
|
+
const { base, repo, env } = fixture();
|
|
361
|
+
try {
|
|
362
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
|
|
363
|
+
writeFileSync(join(repo, 'notes.md'), 'bg-primary');
|
|
364
|
+
writeFileSync(join(repo, '.git', 'info', 'exclude'), 'notes.md\n');
|
|
365
|
+
expect(scanLeaks(repo, env)?.map((l) => [l.path, l.source])).toEqual([['notes.md', '.git/info/exclude:1']]);
|
|
366
|
+
} finally {
|
|
367
|
+
rmSync(base, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('an app root that is itself git-ignored cannot be measured — it throws (the CLI exits 2)', () => {
|
|
372
|
+
const { base, repo, env } = fixture();
|
|
373
|
+
try {
|
|
374
|
+
expect(() => scanLeaks(join(repo, '.agent.noindex'), env)).toThrow(/itself git-ignored/);
|
|
375
|
+
} finally {
|
|
376
|
+
rmSync(base, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('outside any git work tree there is nothing to disagree about — null, not []', () => {
|
|
381
|
+
const dir = mkdtempSync(join(tmpdir(), 'belt-scan-leak-nogit-'));
|
|
382
|
+
try {
|
|
383
|
+
expect(scanLeaks(dir, noRepoEnv(dir))).toBeNull();
|
|
384
|
+
} finally {
|
|
385
|
+
rmSync(dir, { recursive: true, force: true });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('the CLI exits 1 on a leak even when every class is styled, and 0 once the repo ignores it', () => {
|
|
390
|
+
const { base, repo, env } = fixture();
|
|
391
|
+
try {
|
|
392
|
+
writeFileSync(join(repo, 'dist', 'index.css'), '.truncate{overflow:hidden}');
|
|
393
|
+
const run = () =>
|
|
394
|
+
Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), 'dist'], { cwd: repo, env, stdout: 'pipe', stderr: 'pipe' });
|
|
395
|
+
const red = run();
|
|
396
|
+
expect(red.exitCode).toBe(1);
|
|
397
|
+
expect(red.stderr.toString()).toContain('.agent.noindex/');
|
|
398
|
+
expect(red.stderr.toString()).toContain('src/probe.agent.tsx');
|
|
399
|
+
// --root reads the same repo from anywhere.
|
|
400
|
+
const elsewhere = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), join(repo, 'dist'), '--root', repo], {
|
|
401
|
+
cwd: base,
|
|
402
|
+
env,
|
|
403
|
+
stdout: 'pipe',
|
|
404
|
+
stderr: 'pipe',
|
|
405
|
+
});
|
|
406
|
+
expect(elsewhere.exitCode).toBe(1);
|
|
407
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
|
|
408
|
+
const green = run();
|
|
409
|
+
expect(green.stderr.toString()).toBe('');
|
|
410
|
+
expect(green.exitCode).toBe(0);
|
|
411
|
+
} finally {
|
|
412
|
+
rmSync(base, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -35,15 +35,27 @@
|
|
|
35
35
|
* component that reads one by name from a style prop is invisible to the class check above — the
|
|
36
36
|
* hole station's frozen DataTable column fell through (F4, 2026-09-23). Exit 1 as well.
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* ── …and whether the build could have SEEN files the repo does not own (5.3.1) ───────────
|
|
39
|
+
* {@link scanLeaks}: Tailwind v4's automatic source detection honours the repo's OWN `.gitignore`
|
|
40
|
+
* files but NOT the machine-global `core.excludesFile`. So agent scratch — `.agent.noindex/`,
|
|
41
|
+
* `*.agent.*`, ignored only globally — is scanned, adds its classes to the build, and every check
|
|
42
|
+
* above passes on a stylesheet a clean checkout cannot reproduce. Measured in apps/family
|
|
43
|
+
* (0a298a5, 2026-09-23): its first "core only" adoption was green, and a clean build was 65
|
|
44
|
+
* cursedbelt classes short. Any text file under the app root that git ignores through something
|
|
45
|
+
* other than a repo `.gitignore` (`git check-ignore -v` names the file that matched) is exit 1;
|
|
46
|
+
* the fix is the line in the repo's own `.gitignore`, which Tailwind does read.
|
|
47
|
+
*
|
|
48
|
+
* Usage, from an app ROOT (the directory Tailwind scans), after its build:
|
|
49
|
+
* bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist [--root <app root, default cwd>]
|
|
40
50
|
* Exit 0 = complete, 1 = a class is unstyled (listed with its area), a base utility follows a
|
|
41
|
-
* variant,
|
|
51
|
+
* variant, a theme variable read is declared nowhere, or a scanned file is ignored only
|
|
52
|
+
* outside the repo's own `.gitignore`; 2 = could not measure
|
|
42
53
|
* (no JS or no CSS under the directory — a check that measured nothing has not passed).
|
|
43
54
|
*/
|
|
44
|
-
import {
|
|
55
|
+
import { spawnSync } from 'node:child_process';
|
|
56
|
+
import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
45
57
|
import { createRequire } from 'node:module';
|
|
46
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
58
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
47
59
|
import { fileURLToPath } from 'node:url';
|
|
48
60
|
|
|
49
61
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -354,6 +366,105 @@ export function readThemeVariables(src: string | undefined = SRC): Set<string> {
|
|
|
354
366
|
return out;
|
|
355
367
|
}
|
|
356
368
|
|
|
369
|
+
// ── the SCAN-LEAK check (5.3.1) ───────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
export interface ScanLeak {
|
|
372
|
+
/** The ignored path, relative to the app root (`.agent.noindex/`, `notes.agent.md`). */
|
|
373
|
+
path: string;
|
|
374
|
+
/** The ignore file that matched — never a repo `.gitignore` — and its line and pattern. */
|
|
375
|
+
source: string;
|
|
376
|
+
pattern: string;
|
|
377
|
+
/** How many TEXT files it holds (what Tailwind's extractor reads). */
|
|
378
|
+
textFiles: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tailwind never scans these; a leak inside them cannot reach the build. */
|
|
382
|
+
const NEVER_SCANNED = new Set(['node_modules', '.git']);
|
|
383
|
+
|
|
384
|
+
/** Text = no NUL byte in the first 8 KB — the test git and Tailwind's extractor both effectively apply. */
|
|
385
|
+
function isTextFile(file: string): boolean {
|
|
386
|
+
const fd = openSync(file, 'r');
|
|
387
|
+
try {
|
|
388
|
+
const buf = Buffer.alloc(8192);
|
|
389
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
390
|
+
return !buf.subarray(0, n).includes(0);
|
|
391
|
+
} finally {
|
|
392
|
+
closeSync(fd);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Text files at or under `path`, not following symlinks, skipping node_modules/.git; stops counting at `cap`. */
|
|
397
|
+
function countTextFiles(path: string, cap = 1000): number {
|
|
398
|
+
let st: ReturnType<typeof lstatSync>;
|
|
399
|
+
try {
|
|
400
|
+
st = lstatSync(path);
|
|
401
|
+
} catch {
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
if (st.isSymbolicLink()) return 0;
|
|
405
|
+
if (st.isFile()) return st.size > 0 && isTextFile(path) ? 1 : 0;
|
|
406
|
+
if (!st.isDirectory()) return 0;
|
|
407
|
+
let n = 0;
|
|
408
|
+
for (const entry of readdirSync(path)) {
|
|
409
|
+
if (NEVER_SCANNED.has(entry)) continue;
|
|
410
|
+
n += countTextFiles(join(path, entry), cap - n);
|
|
411
|
+
if (n >= cap) break;
|
|
412
|
+
}
|
|
413
|
+
return n;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function git(root: string, args: string[], env: NodeJS.ProcessEnv | undefined, input?: string) {
|
|
417
|
+
return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', input, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 🔴 Every path under `root` that exists, would be scanned (a text file, or a directory holding
|
|
422
|
+
* one, outside node_modules/.git), and is ignored by git ONLY through something that is not one of
|
|
423
|
+
* the repo's own `.gitignore` files — `core.excludesFile` (the machine-global one) or
|
|
424
|
+
* `.git/info/exclude`. Tailwind v4 reads neither, so each is a source of classes a clean checkout
|
|
425
|
+
* does not have. `git check-ignore -v` reports the DECIDING pattern, and a repo `.gitignore` outranks
|
|
426
|
+
* both, so a path the repo ignores itself is never reported however the global file also matches.
|
|
427
|
+
*
|
|
428
|
+
* Returns `null` when `root` is not inside a git work tree: then no gitignore of any kind applies, to
|
|
429
|
+
* git or to Tailwind, and there is nothing for the two to disagree about. `env` is for tests (a
|
|
430
|
+
* hermetic `GIT_CONFIG_GLOBAL`).
|
|
431
|
+
*/
|
|
432
|
+
export function scanLeaks(root: string, env?: NodeJS.ProcessEnv): ScanLeak[] | null {
|
|
433
|
+
const top = git(root, ['rev-parse', '--show-toplevel'], env);
|
|
434
|
+
if (top.status !== 0) return null;
|
|
435
|
+
const topLevel = top.stdout.trim();
|
|
436
|
+
const rootRel = relative(topLevel, resolve(root));
|
|
437
|
+
if (rootRel && git(root, ['check-ignore', '-q', '--', `${resolve(root)}/`], env).status === 0) {
|
|
438
|
+
// git cannot list "the ignored files under an ignored directory", and which of them Tailwind reads
|
|
439
|
+
// is then a question about ITS walker. An app root is never inside an ignored directory; refuse.
|
|
440
|
+
throw new Error(`${root} is itself git-ignored in ${topLevel} — run from the app's root`);
|
|
441
|
+
}
|
|
442
|
+
const listed = git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z', '--', '.'], env);
|
|
443
|
+
if (listed.status !== 0) throw new Error(`git ls-files failed in ${root}: ${listed.stderr.trim()}`);
|
|
444
|
+
const paths = listed.stdout
|
|
445
|
+
.split('\0')
|
|
446
|
+
.filter((p) => p.length > 0)
|
|
447
|
+
.filter((p) => !p.split('/').some((seg) => NEVER_SCANNED.has(seg)));
|
|
448
|
+
if (paths.length === 0) return [];
|
|
449
|
+
const matched = git(root, ['check-ignore', '-v', '-z', '--stdin'], env, `${paths.join('\0')}\0`);
|
|
450
|
+
// Exit 1 = none of them ignored (cannot happen for ls-files -i output, but is not an error).
|
|
451
|
+
if (matched.status !== 0 && matched.status !== 1) throw new Error(`git check-ignore failed in ${root}: ${matched.stderr.trim()}`);
|
|
452
|
+
const fields = matched.stdout.split('\0');
|
|
453
|
+
const leaks: ScanLeak[] = [];
|
|
454
|
+
for (let i = 0; i + 3 < fields.length; i += 4) {
|
|
455
|
+
const [source, line, pattern, path] = fields.slice(i, i + 4) as [string, string, string, string];
|
|
456
|
+
if (!source) continue;
|
|
457
|
+
const abs = isAbsolute(source) ? source : resolve(root, source);
|
|
458
|
+
const inRepo = !relative(topLevel, abs).startsWith('..') && !isAbsolute(relative(topLevel, abs));
|
|
459
|
+
const inGitDir = relative(topLevel, abs).split(sep)[0] === '.git';
|
|
460
|
+
if (basename(abs) === '.gitignore' && inRepo && !inGitDir) continue; // the repo's own — Tailwind reads it
|
|
461
|
+
const textFiles = countTextFiles(join(root, path));
|
|
462
|
+
if (textFiles === 0) continue; // .DS_Store, a screenshot: nothing an extractor could read
|
|
463
|
+
leaks.push({ path, source: `${source}:${line}`, pattern, textFiles });
|
|
464
|
+
}
|
|
465
|
+
return leaks.sort((a, b) => a.path.localeCompare(b.path));
|
|
466
|
+
}
|
|
467
|
+
|
|
357
468
|
function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
|
|
358
469
|
for (const entry of readdirSync(dir)) {
|
|
359
470
|
if (entry === 'node_modules') continue;
|
|
@@ -428,7 +539,11 @@ export function checkDist(distDir: string): AreaCheckResult {
|
|
|
428
539
|
}
|
|
429
540
|
|
|
430
541
|
if (import.meta.main) {
|
|
431
|
-
const
|
|
542
|
+
const args = process.argv.slice(2);
|
|
543
|
+
const rootAt = args.indexOf('--root');
|
|
544
|
+
const root = resolve(rootAt >= 0 ? (args[rootAt + 1] ?? '.') : '.');
|
|
545
|
+
if (rootAt >= 0) args.splice(rootAt, 2);
|
|
546
|
+
const dir = resolve(args[0] ?? 'dist');
|
|
432
547
|
if (!existsSync(dir)) {
|
|
433
548
|
console.error(`✗ ${dir} does not exist — build the app first. A check that measured nothing has not passed.`);
|
|
434
549
|
process.exit(2);
|
|
@@ -438,6 +553,22 @@ if (import.meta.main) {
|
|
|
438
553
|
console.error(`✗ ${dir} holds ${result.jsFiles} .js and ${result.cssFiles} .css file(s) — nothing to compare.`);
|
|
439
554
|
process.exit(2);
|
|
440
555
|
}
|
|
556
|
+
let leaks: ScanLeak[];
|
|
557
|
+
try {
|
|
558
|
+
leaks = scanLeaks(root) ?? [];
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error(`✗ could not tell which files Tailwind scanned: ${(e as Error).message}`);
|
|
561
|
+
process.exit(2);
|
|
562
|
+
}
|
|
563
|
+
if (leaks.length > 0) {
|
|
564
|
+
console.error(
|
|
565
|
+
`✗ ${leaks.length} path(s) under ${root} are ignored by git only OUTSIDE the repo's own .gitignore — Tailwind v4 scans them anyway,`,
|
|
566
|
+
);
|
|
567
|
+
console.error(' so their classes are in this build and a clean checkout\'s build would be short of them (apps/family 0a298a5: 65 classes):');
|
|
568
|
+
for (const l of leaks.slice(0, 12)) console.error(` ${l.path} (${l.textFiles} text file(s); ${l.source} \`${l.pattern}\`)`);
|
|
569
|
+
if (leaks.length > 12) console.error(` … +${leaks.length - 12} more`);
|
|
570
|
+
console.error(" Add each pattern to the repo's own .gitignore (e.g. `.agent.noindex/` and `*.agent.*`), rebuild, and check again.");
|
|
571
|
+
}
|
|
441
572
|
if (result.outOfOrder.length > 0) {
|
|
442
573
|
console.error(`✗ ${result.outOfOrder.length} base utilit(y|ies) come AFTER a variant that sets the same property, so the base wins at every width:`);
|
|
443
574
|
for (const f of result.outOfOrder.slice(0, 12)) console.error(` .${f.base} (later) overrides .${f.variant} — ${f.properties.join(', ')}`);
|
|
@@ -456,10 +587,10 @@ if (import.meta.main) {
|
|
|
456
587
|
console.error(' in a file the app\'s Tailwind pass scans.');
|
|
457
588
|
}
|
|
458
589
|
if (result.missing.length === 0) {
|
|
459
|
-
if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0) process.exit(1);
|
|
590
|
+
if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0 || leaks.length > 0) process.exit(1);
|
|
460
591
|
console.log(
|
|
461
592
|
`✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled, ` +
|
|
462
|
-
`no base utility follows a variant,
|
|
593
|
+
`no base utility follows a variant, every theme variable read is declared, and nothing scanned is ignored only globally (${result.jsFiles} js, ${result.cssFiles} css).`,
|
|
463
594
|
);
|
|
464
595
|
process.exit(0);
|
|
465
596
|
}
|
|
@@ -58,8 +58,11 @@ describe('attachHlsSource — every road ends at something playable', () => {
|
|
|
58
58
|
// master it cannot parse is a permanent spinner, which is worse than the slow copy.
|
|
59
59
|
const el = videoEl(false);
|
|
60
60
|
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Poll, not one tick: the fallback lands after the dynamic `import('hls.js')` settles, and on a
|
|
62
|
+
// cold module cache (publish's `gate --force`, 2026-09-23) that took longer than one macrotask.
|
|
63
|
+
for (const deadline = Date.now() + 5000; el.src === '' && Date.now() < deadline; ) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
65
|
+
}
|
|
63
66
|
expect(el.src).toBe(REMUX);
|
|
64
67
|
detach();
|
|
65
68
|
});
|