diff --git a/.gitignore b/.gitignore index dd50108f..e86bc179 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage .env.local *.tsbuildinfo .pnpm-store +__pycache__/ docs/reports/ # Step-CA dev password — real file is gitignored; commit only the .example diff --git a/.husky/pre-push b/.husky/pre-push index 4a0f0e8a..aae2e7e1 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm typecheck && pnpm lint && pnpm format:check +pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check diff --git a/.npmrc b/.npmrc index e72177a2..17a95ce4 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ -# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci), -# so the pipeline `pnpm install --prefer-offline` consumes the baked store -# instead of repopulating a fresh one. -store-dir=/root/.local/share/pnpm/store +# HOME resolves to /root in the ci-base image, preserving its warmed-store path. +# Non-root checkouts use their own HOME. Override without editing this file via +# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting). +store-dir=${HOME}/.local/share/pnpm/store diff --git a/README.md b/README.md index f095ee62..cefdc60d 100644 --- a/README.md +++ b/README.md @@ -201,8 +201,14 @@ git clone git@git.mosaicstack.dev:mosaicstack/stack.git cd stack # Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset. +# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without +# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed. pnpm install +# Verify dependencies and generated state before running source-quality gates. +# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. +pnpm preflight + # Optional local queue service only. This does not start PostgreSQL. docker compose up -d valkey @@ -230,6 +236,7 @@ Gateway start command until KBN-101-02 makes that state fail closed. ### Quality Gates ```bash +pnpm preflight # Checkout/dependency/generated-state validation pnpm typecheck # TypeScript type checking (all packages) pnpm lint # ESLint (all packages) pnpm test # Vitest (all packages) diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md new file mode 100644 index 00000000..a536c951 --- /dev/null +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -0,0 +1,20 @@ +# RM-01 — Reproducible checkout + +- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking) +- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats. +- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation. +- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge. +- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum. +- Plan: + 1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation. + 2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation. + 3. Run focused tests, install/build/baseline gates, and explicit AC negative controls. + 4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`. +- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup. +- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification. + +## Progress / evidence + +- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`. +- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`. +- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness. diff --git a/package.json b/package.json index fb75bde1..f52dd9d6 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", - "typecheck": "turbo run typecheck", - "test": "turbo run test", + "preflight": "node scripts/preflight.mjs", + "clean:generated": "node scripts/clean-generated.mjs", + "typecheck": "pnpm preflight && turbo run typecheck", + "test:checkout": "node --test scripts/*.test.mjs", + "test": "pnpm test:checkout && turbo run test", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", - "prepare": "husky" + "prepare": "node scripts/install-hooks.mjs" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/scripts/clean-generated.mjs b/scripts/clean-generated.mjs new file mode 100644 index 00000000..54e42c84 --- /dev/null +++ b/scripts/clean-generated.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { access, mkdir, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); +const generated = path.join(root, 'apps', 'web', '.next'); +const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine'); + +try { + await access(generated); +} catch (error) { + if (error.code === 'ENOENT') process.exit(0); + throw error; +} + +await mkdir(quarantineRoot, { recursive: true }); +const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`); +try { + await rename(generated, quarantine); +} catch (error) { + console.error( + `MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`, + ); + throw error; +} + +try { + await rm(quarantine, { recursive: true, force: true }); +} catch { + console.warn( + `Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`, + ); +} diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 00000000..8c9394ab --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const execFileAsync = promisify(execFile); + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`)); + }); + }); +} + +async function pathExists(target) { + try { + await access(target); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +async function directorySnapshot(root) { + const snapshot = []; + async function walk(current) { + const children = await readdir(current, { withFileTypes: true }); + for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) { + const target = path.join(current, child.name); + const relative = path.relative(root, target); + const stats = await lstat(target); + if (child.isDirectory()) { + snapshot.push([relative, 'directory', stats.mode & 0o777]); + await walk(target); + } else { + snapshot.push([ + relative, + 'file', + stats.mode & 0o777, + (await readFile(target)).toString('base64'), + ]); + } + } + } + await walk(root); + return JSON.stringify(snapshot); +} + +async function directoriesMatch(left, right) { + return (await directorySnapshot(left)) === (await directorySnapshot(right)); +} + +export async function installHooks({ + root = process.cwd(), + disabled = process.env.HUSKY === '0', + quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'), + runHusky = async (_stagingHooks, stagingRepo) => { + await execFileAsync('git', ['init', '--quiet', stagingRepo]); + await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], { + cwd: stagingRepo, + stdio: 'inherit', + }); + }, + activateHooks = async () => { + await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' }); + }, + readHooksPath = async () => { + try { + const { stdout } = await execFileAsync('git', ['config', '--get', 'core.hooksPath'], { + cwd: root, + }); + return stdout.trim() || null; + } catch (error) { + if (error.code === 1) return null; + throw error; + } + }, + restoreHooksPath = async (previous) => { + if (previous === null) { + try { + await execFileAsync('git', ['config', '--unset', 'core.hooksPath'], { cwd: root }); + } catch (error) { + if (error.code !== 5) throw error; + } + } else { + await execFileAsync('git', ['config', 'core.hooksPath', previous], { cwd: root }); + } + }, +} = {}) { + if (disabled) return; + + const huskyDir = path.join(root, '.husky'); + const active = path.join(huskyDir, '_'); + const nonce = `${Date.now()}-${process.pid}`; + const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`); + const stagingHooks = path.join(stagingRepo, '.husky'); + const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`); + + const previousHooksPath = await readHooksPath(); + await mkdir(huskyDir, { recursive: true }); + await mkdir(quarantineRoot, { recursive: true }); + + const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h'))); + let previousQuarantined = false; + try { + if ((await pathExists(active)) && !previousComplete) { + await rename(active, quarantined); + previousQuarantined = true; + } + await mkdir(stagingRepo, { recursive: true }); + await runHusky(stagingHooks, stagingRepo); + const staged = path.join(stagingHooks, '_'); + if (!(await pathExists(path.join(staged, 'h')))) { + throw new Error('husky did not produce its required h shim'); + } + if (previousComplete) { + if (!(await directoriesMatch(active, staged))) { + throw new Error('existing complete hook set differs from the installed Husky version'); + } + await rm(stagingRepo, { recursive: true, force: true }); + } else { + await rename(staged, active); + await rm(stagingRepo, { recursive: true, force: true }); + } + await activateHooks(); + if (previousQuarantined) { + try { + await rm(quarantined, { recursive: true, force: true }); + } catch { + console.warn( + `Previous hook state was deactivated but remains quarantined at ${quarantined}`, + ); + } + } + } catch (error) { + const cleanupFailures = []; + try { + if ((await pathExists(active)) && !previousComplete) { + await rename(active, `${quarantined}-failed`); + } + } catch (cleanupError) { + cleanupFailures.push(`active hooks: ${cleanupError.message}`); + } + try { + if (await pathExists(stagingRepo)) { + await rename(stagingRepo, `${quarantined}-staging`); + } + } catch (cleanupError) { + cleanupFailures.push(`staging hooks: ${cleanupError.message}`); + } + try { + await restoreHooksPath(previousHooksPath); + } catch (cleanupError) { + cleanupFailures.push(`core.hooksPath: ${cleanupError.message}`); + } + const cleanup = + cleanupFailures.length === 0 + ? 'No partial hook set was activated.' + : `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`; + throw new Error( + `Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`, + { cause: error }, + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await installHooks(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/install-hooks.test.mjs b/scripts/install-hooks.test.mjs new file mode 100644 index 00000000..1b5087dc --- /dev/null +++ b/scripts/install-hooks.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { installHooks } from './install-hooks.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`); +const quarantineRoot = path.join(fixtureRoot, 'quarantine'); + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, '.husky'), { recursive: true }); + return root; +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('an interrupted install quarantines the partial active hook set and fails loudly', async () => { + const root = await fixture('interrupted'); + let restoredHooksPath = 'not-called'; + + await assert.rejects( + installHooks({ + root, + quarantineRoot, + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'partial'); + throw new Error('simulated interruption'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async (value) => { + restoredHooksPath = value; + }, + }), + (error) => { + assert.match(error.message, /Hook installation failed/); + assert.match(error.message, /pnpm install --frozen-lockfile/); + return true; + }, + ); + + assert.equal(await exists(path.join(root, '.husky', '_')), false); + assert.equal(restoredHooksPath, null); + const quarantined = await readdir(quarantineRoot); + assert.equal(quarantined.length, 1); +}); + +test('a failed replacement restores a previously complete active hook set', async () => { + const root = await fixture('rollback'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'previous-complete'); + let previousRemainedActiveDuringStaging = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'), + runHusky: async () => { + previousRemainedActiveDuringStaging = + (await readFile(activeShim, 'utf8')) === 'previous-complete'; + throw new Error('simulated replacement failure'); + }, + activateHooks: async () => {}, + readHooksPath: async () => '.husky/_', + restoreHooksPath: async () => {}, + }), + /Hook installation failed/, + ); + + assert.equal(previousRemainedActiveDuringStaging, true); + assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete'); +}); + +test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => { + const root = await fixture('mismatch'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'old-complete'); + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete'); + }, + activateHooks: async () => {}, + readHooksPath: async () => '.husky/_', + restoreHooksPath: async () => {}, + }), + /Hook installation failed.*pnpm install --frozen-lockfile/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'old-complete'); +}); + +test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => { + const root = await fixture('disabled'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'preserved'); + let ran = false; + + await installHooks({ + root, + disabled: true, + runHusky: async () => { + ran = true; + }, + }); + + assert.equal(ran, false); + assert.equal(await readFile(activeShim, 'utf8'), 'preserved'); +}); + +test('a successful install leaves a complete active hook set', async () => { + const root = await fixture('success'); + + await installHooks({ + root, + quarantineRoot, + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'complete'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async () => {}, + }); + + assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true); +}); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs new file mode 100644 index 00000000..7c2602e5 --- /dev/null +++ b/scripts/preflight.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import { constants } from 'node:fs'; +import { access, lstat, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +export const MISSING_DEPS_EXIT = 42; +export const GENERATED_STATE_EXIT = 43; + +async function entries(root) { + const result = []; + async function walk(current) { + let children; + try { + children = await readdir(current, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return; + throw error; + } + for (const child of children) { + const target = path.join(current, child.name); + result.push(target); + if (child.isDirectory() && !child.isSymbolicLink()) await walk(target); + } + } + await walk(root); + return result; +} + +async function fileMtimeRange(paths) { + let newest = 0; + let oldest = Number.POSITIVE_INFINITY; + for (const target of paths) { + const stats = await lstat(target); + if (stats.isFile()) { + newest = Math.max(newest, stats.mtimeMs); + oldest = Math.min(oldest, stats.mtimeMs); + } + } + return { newest, oldest: Number.isFinite(oldest) ? oldest : 0 }; +} + +export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) { + const binDir = path.join(root, 'node_modules', '.bin'); + const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; + const missingBinaries = []; + for (const binary of requiredBinaries) { + try { + await access(path.join(binDir, binary), constants.X_OK); + } catch { + missingBinaries.push(binary); + } + } + if (missingBinaries.length > 0) { + return { + code: MISSING_DEPS_EXIT, + message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`, + }; + } + + const nextDir = path.join(root, 'apps', 'web', '.next'); + let generated = []; + try { + await lstat(nextDir); + generated = [nextDir, ...(await entries(nextDir))]; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + if (generated.length > 0) { + const foreign = []; + if (uid !== undefined) { + for (const target of generated) { + if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target)); + } + } + + const sourceRoots = [ + path.join(root, 'apps', 'web', 'src'), + path.join(root, 'apps', 'web', 'next.config.ts'), + path.join(root, 'apps', 'web', 'package.json'), + path.join(root, 'apps', 'web', 'tsconfig.json'), + ]; + const source = []; + for (const sourceRoot of sourceRoots) { + try { + const stats = await lstat(sourceRoot); + source.push(sourceRoot); + if (stats.isDirectory()) source.push(...(await entries(sourceRoot))); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + + const generatedTypes = generated.filter((target) => + target.startsWith(path.join(nextDir, 'types') + path.sep), + ); + const sourceMtime = await fileMtimeRange(source); + const generatedMtime = await fileMtimeRange(generatedTypes); + const stale = + source.length > 0 && generatedTypes.length > 0 && sourceMtime.newest > generatedMtime.oldest; + if (foreign.length > 0 || stale) { + const reasons = [ + foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '', + stale ? 'generated output is older than web source/configuration' : '', + ].filter(Boolean); + return { + code: GENERATED_STATE_EXIT, + message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`, + }; + } + } + + return { code: 0, message: 'checkout preflight passed' }; +} + +async function main() { + const result = await runPreflight(); + const stream = result.code === 0 ? process.stdout : process.stderr; + stream.write(`${result.message}\n`); + process.exitCode = result.code; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs new file mode 100644 index 00000000..0d63fb3c --- /dev/null +++ b/scripts/preflight.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { runPreflight } from './preflight.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`); + +const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true }); + await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n'); + return root; +} + +async function installRequiredBins(root) { + const binDir = path.join(root, 'node_modules', '.bin'); + await mkdir(binDir, { recursive: true }); + await Promise.all( + requiredBins.map(async (name) => { + const target = path.join(binDir, name); + await writeFile(target, ''); + await chmod(target, 0o755); + }), + ); +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('missing dependencies have a dedicated exit code and install remediation', async () => { + const root = await fixture('missing-deps'); + const result = await runPreflight({ root }); + + assert.equal(result.code, 42); + assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/); + assert.match(result.message, /run pnpm install/i); +}); + +test('a partial dependency install keeps the dedicated missing-deps result', async () => { + const root = await fixture('partial-deps'); + await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true }); + await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 }); + + const result = await runPreflight({ root }); + assert.equal(result.code, 42); + assert.match(result.message, /turbo/); +}); + +test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => { + const root = await fixture('dangling-deps'); + await installRequiredBins(root); + const turbo = path.join(root, 'node_modules', '.bin', 'turbo'); + await rm(turbo); + await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo); + + const result = await runPreflight({ root }); + assert.equal(result.code, 42); + assert.match(result.message, /turbo/); +}); + +test('installed dependencies pass when generated state is absent', async () => { + const root = await fixture('clean'); + await installRequiredBins(root); + + assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); +}); + +test('foreign-owned generated Next state is identified separately from source errors', async () => { + const root = await fixture('foreign-next'); + await installRequiredBins(root); + const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); + await mkdir(path.dirname(generated), { recursive: true }); + await writeFile(generated, 'generated output'); + + const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /foreign-owned/); +}); + +test('stale generated Next state is identified separately from source errors', async () => { + const root = await fixture('stale-next'); + await installRequiredBins(root); + const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); + await mkdir(path.dirname(generated), { recursive: true }); + await writeFile(generated, 'stale generated output'); + const old = new Date('2020-01-01T00:00:00Z'); + await utimes(generated, old, old); + const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); + await writeFile(fresh, 'fresh generated output'); + const future = new Date('2030-01-01T00:00:00Z'); + await utimes(fresh, future, future); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /apps\/web\/\.next/); + assert.match(result.message, /pnpm clean:generated/); +});