diff --git a/scripts/lint-no-hardcoded-runners.mjs b/scripts/lint-no-hardcoded-runners.mjs index 3070963c8..26c6a2548 100644 --- a/scripts/lint-no-hardcoded-runners.mjs +++ b/scripts/lint-no-hardcoded-runners.mjs @@ -28,7 +28,26 @@ const TARGETS = process.argv.slice(2).length const NPX_TOKEN = /['"`]npx\b|(?:^|[^a-zA-Z0-9_$])npx\s+[@\w-]/ async function* walk(dir) { - const entries = await readdir(dir, { withFileTypes: true }) + // A directory can vanish between the parent's `readdir` and this call. The + // repo's own script self-tests create and delete probe packages under + // `packages/` (`lint-untracked-probe`, `lint-dead-shell-probe` in + // `lint-no-dead-package-paths.test.mjs`) while other suites run, so a walk of + // `packages/` can enumerate one and find it gone a moment later. + // + // Left unhandled this rejects with ENOENT, and Node exits **1** — the same + // code as "found a hardcoded npx". Every caller then reads a crash as a + // violation: CI fails naming a file that is perfectly clean, and re-running + // it passes. The missing-target case deliberately exits 2 for exactly this + // reason ("Exit 2 means the linter could not run"); this is the same + // distinction, one level down. There is nothing to lint in a directory that + // no longer exists, so skip it. + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch (err) { + if (err?.code === 'ENOENT') return + throw err + } for (const entry of entries) { const full = join(dir, entry.name) if (entry.isDirectory()) { diff --git a/scripts/vitest.config.mjs b/scripts/vitest.config.mjs index 2a918fc98..5db344161 100644 --- a/scripts/vitest.config.mjs +++ b/scripts/vitest.config.mjs @@ -1,4 +1,26 @@ import { defineConfig } from 'vitest/config' + +// `fileParallelism: false` is load-bearing, not a performance knob. +// +// Several of these suites mutate the real repo tree while they run: +// `lint-no-dead-package-paths.test.mjs` creates and deletes probe package +// directories inside the packages tree (`lint-untracked-probe`, +// `lint-dead-shell-probe`), and `lint-no-hardcoded-runners.test.mjs` walks that +// same tree and writes an `allowlist-probe.mjs` into `scripts/`. Run in +// parallel, one suite's scratch state lands inside another's scan — which is +// how a clean tree produced an `ENOENT: scandir` on a probe directory and a CI +// failure that pointed at nothing. +// +// Note the probe names are deliberately written WITHOUT their directory prefix: +// the sibling `lint-no-dead-package-paths` linter flags that shape when the +// directory does not exist, and these only exist mid-test. +// +// These are eleven small files; serialising them costs a second and removes +// the whole class of cross-suite filesystem race. export default defineConfig({ - test: { include: ['scripts/__tests__/**/*.test.mjs'], pool: 'forks' }, + test: { + include: ['scripts/__tests__/**/*.test.mjs'], + pool: 'forks', + fileParallelism: false, + }, })