diff --git a/README.md b/README.md index e398883a5..74ced8387 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6984+ tests / 577 files + 6988+ tests / 578 files Codecov Coverage License MIT CI Status @@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (6984+ tests / 577 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6988+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -550,7 +550,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (6984+ tests, 577 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6988+ tests, 578 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **6984+ unit tests** across **577 test files** — CI is authoritative for pass/fail +- **6988+ unit tests** across **578 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index 4dae666ad..82a6d2f20 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -13,13 +13,13 @@ function report(name, status, detail = '') { return status; } -function runCheck(name, run) { - const status = run(); +async function runCheck(name, run) { + const status = await run(); report(name, status === 0 ? 'PASS' : 'FAIL'); if (status !== 0) process.exit(status ?? 1); } -function main() { +async function main() { const evidenceIndex = process.argv.indexOf('--prepush-evidence-file'); const evidenceFile = evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : undefined; const fullRequested = process.argv.includes('--full'); @@ -51,18 +51,18 @@ function main() { } report('Dependency state', 'PASS'); - runCheck('Toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])); - runCheck('Docs/release truth', () => runNodeScript('scripts/check-doc-metrics.mjs')); - runCheck('CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')); - runCheck('Desktop import boundary', () => + await runCheck('Toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])); + await runCheck('Docs/release truth', () => runNodeScript('scripts/check-doc-metrics.mjs')); + await runCheck('CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')); + await runCheck('Desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs'), ); - runCheck('Native readiness', () => runNodeScript('scripts/check-native-readiness.mjs')); + await runCheck('Native readiness', () => runNodeScript('scripts/check-native-readiness.mjs')); if (shouldRunAdmissionCheck('i18n', classification.files) || full) { - runCheck('i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs')); - runCheck('i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs')); - runCheck('i18n translation quality', () => + await runCheck('i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs')); + await runCheck('i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs')); + await runCheck('i18n translation quality', () => runNodeScript('scripts/i18n-quality-report.mjs', [ '--strict', '--min-coverage', @@ -74,10 +74,10 @@ function main() { } if (shouldRunAdmissionCheck('contentGuard', classification.files) || full) - runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs')); + await runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs')); if (typecheckRequired) { - runCheck('TypeScript (single checker)', () => + await runCheck('TypeScript (single checker)', () => // QNBS-v3: one checker bounds memory use on constrained developer machines. runLocalBinary('tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1']), ); @@ -92,4 +92,4 @@ function main() { } // QNBS-v3: guard execution so this module can be imported for testing without running the CLI. -if (isMainModule(process.argv[1], import.meta.url)) main(); +if (isMainModule(process.argv[1], import.meta.url)) await main(); diff --git a/scripts/hooks/pre-commit.mjs b/scripts/hooks/pre-commit.mjs index 9a6ef9eec..42fae9771 100644 --- a/scripts/hooks/pre-commit.mjs +++ b/scripts/hooks/pre-commit.mjs @@ -1,6 +1,6 @@ import process from 'node:process'; import { ensureDependencyState, runLocalBinary, runNodeScript } from './shared.mjs'; -if (runNodeScript('scripts/signing/doctor.mjs', ['--hook']) !== 0) process.exit(1); +if ((await runNodeScript('scripts/signing/doctor.mjs', ['--hook'])) !== 0) process.exit(1); if (!ensureDependencyState()) process.exit(1); -process.exit(runLocalBinary('lint-staged')); +process.exit(await runLocalBinary('lint-staged')); diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs index c97c0a1b5..0de69a923 100644 --- a/scripts/hooks/pre-push.mjs +++ b/scripts/hooks/pre-push.mjs @@ -18,8 +18,8 @@ try { evidenceFile = join(evidenceDir, 'evidence.json'); writePrePushEvidenceFile(evidenceFile, updates); const childArgs = [...process.argv.slice(2), '--prepush-evidence-file', evidenceFile]; - exitCode = runNodeScript('scripts/signing/verify-outgoing.mjs', childArgs); - if (exitCode === 0) exitCode = runNodeScript('scripts/ci-prepush-lowend.mjs', childArgs); + exitCode = await runNodeScript('scripts/signing/verify-outgoing.mjs', childArgs); + if (exitCode === 0) exitCode = await runNodeScript('scripts/ci-prepush-lowend.mjs', childArgs); } catch (error) { console.error( `pre-push evidence capture failed closed: ${error instanceof Error ? error.message : 'invalid input'}`, diff --git a/scripts/hooks/shared.d.mts b/scripts/hooks/shared.d.mts new file mode 100644 index 000000000..8cb6c7161 --- /dev/null +++ b/scripts/hooks/shared.d.mts @@ -0,0 +1,54 @@ +export interface BoundedResult { + status: number | null; + signal: string | null; + error: Error | null; + timedOut: boolean; + interrupted: boolean; + command: string; +} + +export interface BoundedOptions { + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + input?: string; + shell?: boolean; + cwd?: string; + detached?: boolean; +} + +// QNBS-v3: root is a wrapper-only option; runBounded's runtime destructuring never consumes it. +export interface RunOptions extends BoundedOptions { + root?: string; +} + +export function ensureDependencyState(root?: string): boolean; + +export function runBounded( + command: string, + args: string[], + options?: BoundedOptions, +): Promise; + +export function runNodeScriptDetailed( + script: string, + args?: string[], + options?: RunOptions, +): Promise; + +export function runNodeScript( + script: string, + args?: string[], + options?: RunOptions, +): Promise; + +export function runLocalBinaryDetailed( + binary: string, + args?: string[], + options?: RunOptions, +): Promise; + +export function runLocalBinary( + binary: string, + args?: string[], + options?: RunOptions, +): Promise; diff --git a/scripts/hooks/shared.mjs b/scripts/hooks/shared.mjs index 5785f8e92..b6f6acbbc 100644 --- a/scripts/hooks/shared.mjs +++ b/scripts/hooks/shared.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import process from 'node:process'; @@ -7,9 +7,9 @@ import { verifyDependencyState } from '../dependency-state.mjs'; const projectRoot = resolve(fileURLToPath(new URL('../..', import.meta.url))); -export function ensureDependencyState() { +export function ensureDependencyState(root = projectRoot) { try { - verifyDependencyState(projectRoot); + verifyDependencyState(root); return true; } catch (error) { console.error(`[hook] ${error instanceof Error ? error.message : String(error)}`); @@ -18,17 +18,195 @@ export function ensureDependencyState() { } } -export function runNodeScript(script, args = []) { - const result = spawnSync(process.execPath, [resolve(projectRoot, script), ...args], { - cwd: projectRoot, - stdio: 'inherit', +// QNBS-v3: bound hook children so timeout or resource termination is observable instead of an implicit pass. +export function runBounded( + command, + args, + { + timeoutMs = 120_000, + env, + input, + shell = false, + cwd = projectRoot, + detached = process.platform !== 'win32', + } = {}, +) { + return new Promise((resolveResult) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + shell, + detached: detached && process.platform !== 'win32', + stdio: input === undefined ? 'inherit' : ['pipe', 'inherit', 'inherit'], + }); + let timedOut = false; + let interrupted = false; + let terminationRequested = false; + let cleanupStarted = false; + let cleanupDeadline = 0; + let pendingFinish = null; + let state = 'RUNNING'; + let settled = false; + let forceTimer; + let childExited = false; + child.once('exit', () => { + childExited = true; + }); + const terminate = (signal) => { + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // Fall back to the direct child when a process group is unavailable. + } + } else if (process.platform === 'win32' && child.pid) { + const result = spawnSync( + 'taskkill', + ['/pid', String(child.pid), '/t', ...(signal === 'SIGKILL' ? ['/f'] : [])], + { windowsHide: true, stdio: 'ignore' }, + ); + if (result.status === 0) return; + } + try { + child.kill(signal); + } catch { + // The child may have exited between process-group and direct cleanup attempts. + } + }; + const cleanupComplete = () => { + if (!child.pid) return true; + if (process.platform === 'win32') { + // QNBS-v3: taskkill /f returning does not prove the tree exited; poll tasklist instead. + const result = spawnSync('tasklist', ['/fi', `PID eq ${child.pid}`, '/fo', 'csv', '/nh'], { + windowsHide: true, + }); + return !(result.stdout ?? '').toString().includes(String(child.pid)); + } + try { + process.kill(-child.pid, 0); + return false; + } catch (error) { + // QNBS-v3: only ESRCH proves the group is gone; EPERM/unknown errors must not short-circuit polling. + return error?.code === 'ESRCH'; + } + }; + const finishAfterCleanup = () => { + if (!cleanupComplete() && Date.now() < cleanupDeadline) { + setTimeout(finishAfterCleanup, 20); + return; + } + complete( + pendingFinish?.status ?? null, + pendingFinish?.signal ?? 'SIGKILL', + pendingFinish?.error ?? null, + ); + }; + const beginForceCleanup = () => { + if (cleanupStarted || state === 'SETTLED') return; + cleanupStarted = true; + state = 'FORCE_CLEANUP_RUNNING'; + if (forceTimer) { + clearTimeout(forceTimer); + forceTimer = undefined; + } + terminate('SIGKILL'); + cleanupDeadline = Date.now() + 1_000; + finishAfterCleanup(); + }; + const scheduleForceTermination = () => { + if (forceTimer) clearTimeout(forceTimer); + state = 'FORCE_CLEANUP_PENDING'; + forceTimer = setTimeout(() => { + forceTimer = undefined; + beginForceCleanup(); + }, 1_000); + }; + const requestTermination = (signal, reason, error = null) => { + if (reason === 'timeout') timedOut = true; + else if (reason === 'interrupt') interrupted = true; + if (error) pendingFinish = { status: null, signal: null, error }; + if (terminationRequested) { + // QNBS-v3: a repeated parent signal must force-clean detached children before the grace timer. + beginForceCleanup(); + return; + } + terminationRequested = true; + state = 'TERMINATION_REQUESTED'; + terminate(signal); + scheduleForceTermination(); + }; + const timeoutTimer = setTimeout(() => requestTermination('SIGTERM', 'timeout'), timeoutMs); + const signalHandlers = new Map(); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + const handler = () => { + requestTermination(signal, 'interrupt'); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + const complete = (status, signal, error = null) => { + if (settled) return; + settled = true; + state = 'SETTLED'; + clearTimeout(timeoutTimer); + if (forceTimer) { + clearTimeout(forceTimer); + forceTimer = undefined; + } + for (const [parentSignal, handler] of signalHandlers) { + process.removeListener(parentSignal, handler); + } + resolveResult({ + status: error ? null : status, + signal, + error, + timedOut, + interrupted, + command, + }); + }; + const finish = (status, signal, error = null) => { + if (settled) return; + if (terminationRequested) { + // QNBS-v3: leader close never proves descendants are gone; force cleanup remains authoritative. + pendingFinish ??= { status, signal, error }; + state = 'CLOSED'; + beginForceCleanup(); + return; + } + complete(status, signal, error); + }; + child.once('error', (error) => finish(null, null, error)); + child.once('close', (status, signal) => finish(status, signal)); + if (input !== undefined) { + child.stdin.once('error', (error) => { + // QNBS-v3: a broken pipe only proves the child was already done reading, not that delivery mid-run is safe to ignore. + const benign = childExited && ['EPIPE', 'ERR_STREAM_DESTROYED'].includes(error.code); + if (!benign) requestTermination('SIGTERM', 'resource', error); + }); + child.stdin.end(input); + } }); - return result.error ? 1 : (result.status ?? 1); } -export function runLocalBinary(binary, args = []) { +export async function runNodeScriptDetailed(script, args = [], options = {}) { + const root = options.root ?? projectRoot; + return runBounded(process.execPath, [resolve(root, script), ...args], { + ...options, + cwd: options.cwd ?? root, + }); +} + +export async function runNodeScript(script, args = [], options = {}) { + const result = await runNodeScriptDetailed(script, args, options); + return result.error || result.timedOut || result.interrupted ? 1 : (result.status ?? 1); +} + +export async function runLocalBinaryDetailed(binary, args = [], options = {}) { + const root = options.root ?? projectRoot; const command = resolve( - projectRoot, + root, 'node_modules', '.bin', `${binary}${process.platform === 'win32' ? '.cmd' : ''}`, @@ -37,12 +215,23 @@ export function runLocalBinary(binary, args = []) { console.error( `[hook] Required local binary is missing: ${binary}. Run: node scripts/dependency-state.mjs reconcile`, ); - return 1; + return { + status: 1, + signal: null, + error: new Error(`Missing local binary: ${binary}`), + timedOut: false, + interrupted: false, + command, + }; } - const result = spawnSync(command, args, { - cwd: projectRoot, + return runBounded(command, args, { + ...options, + cwd: options.cwd ?? root, shell: process.platform === 'win32', - stdio: 'inherit', }); - return result.error ? 1 : (result.status ?? 1); +} + +export async function runLocalBinary(binary, args = [], options = {}) { + const result = await runLocalBinaryDetailed(binary, args, options); + return result.error || result.timedOut || result.interrupted ? 1 : (result.status ?? 1); } diff --git a/tests/unit/hooks/shared.test.ts b/tests/unit/hooks/shared.test.ts new file mode 100644 index 000000000..db7f4c125 --- /dev/null +++ b/tests/unit/hooks/shared.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment node +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runBounded, runNodeScriptDetailed } from '../../../scripts/hooks/shared.mjs'; + +describe('bounded hook subprocesses', () => { + it('does not treat a clean timeout shutdown as a successful run', async () => { + const result = await runBounded( + process.execPath, + ['-e', "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 10_000);"], + { timeoutMs: 100 }, + ); + + // QNBS-v3: status is legitimately 0 here (clean exit(0) on SIGTERM); timedOut is the real proof. + expect(result.timedOut).toBe(true); + }); + + // QNBS-v3: keep nested admission checks in the parent's process group for outer cleanup. + it('supports foreground children for nested admission checks', async () => { + const result = await runBounded(process.execPath, ['-e', 'setInterval(() => {}, 10_000);'], { + timeoutMs: 100, + detached: false, + }); + + expect(result.timedOut).toBe(true); + expect(result.status === 0).toBe(false); + }); + + // QNBS-v3: prove repeated parent signals clean detached children without accepting cancellation as pass. + it('preserves parent cancellation and force-cleans after repeated signals', async () => { + const resultPromise = runBounded( + process.execPath, + ['-e', "process.on('SIGINT', () => {}); setInterval(() => {}, 10_000);"], + { timeoutMs: 5_000 }, + ); + const firstSignal = setTimeout(() => process.emit('SIGINT'), 50); + const repeatedSignal = setTimeout(() => process.emit('SIGINT'), 100); + + try { + const result = await resultPromise; + expect(result.interrupted).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.status === 0).toBe(false); + } finally { + clearTimeout(firstSignal); + clearTimeout(repeatedSignal); + } + }); + + describe('runNodeScriptDetailed cwd handling', () => { + const scratchDirs: string[] = []; + afterEach(async () => { + await Promise.all(scratchDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('preserves an explicit cwd separate from root', async () => { + const scriptRoot = await mkdtemp(join(tmpdir(), 'worldscript-hook-root-')); + const workingDir = await mkdtemp(join(tmpdir(), 'worldscript-hook-cwd-')); + scratchDirs.push(scriptRoot, workingDir); + await writeFile( + join(scriptRoot, 'write-marker.mjs'), + "import { writeFileSync } from 'node:fs'; writeFileSync('marker.txt', 'ok');", + 'utf8', + ); + + const result = await runNodeScriptDetailed('write-marker.mjs', [], { + root: scriptRoot, + cwd: workingDir, + }); + + expect(result.status).toBe(0); + await expect(readFile(join(workingDir, 'marker.txt'), 'utf8')).resolves.toBe('ok'); + }); + }); +});