diff --git a/README.md b/README.md
index e398883a5..74ced8387 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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');
+ });
+ });
+});