diff --git a/README.md b/README.md
index ceaf3c849..42975d34a 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 (6954+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (6958+ tests / 575 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 (6954+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (6958+ tests, 575 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):**
-- **6954+ unit tests** across **575 test files** — CI is authoritative for pass/fail
+- **6958+ unit tests** across **575 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 20ba9d4bb..ba8a2facb 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,5 +1,24 @@
import process from 'node:process';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs';
+import { parseSerializedPrePushUpdates, resolvePushEvidence } from './signing/signing-core.mjs';
+
+const serializedUpdates = process.env.WORLD_SCRIPT_PREPUSH_UPDATES;
+if (serializedUpdates) {
+ let evidence;
+ try {
+ evidence = resolvePushEvidence(parseSerializedPrePushUpdates(serializedUpdates), process.cwd());
+ } catch (error) {
+ console.error(`[local-lowend] outgoing evidence capture failed closed: ${error.message}`);
+ process.exit(1);
+ }
+ if (evidence.evidenceState !== 'RESOLVED') {
+ console.error(`[local-lowend] outgoing evidence is invalid: ${evidence.reason}`);
+ process.exit(1);
+ }
+ console.log(
+ `[local-lowend] outgoing evidence resolved: ${evidence.updates.length} update(s), ${evidence.changedFiles.length} changed path(s)`,
+ );
+}
const checks = [
['toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])],
diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs
index 5c8f3aa39..8dff3de12 100644
--- a/scripts/hooks/pre-push.mjs
+++ b/scripts/hooks/pre-push.mjs
@@ -1,6 +1,20 @@
import process from 'node:process';
+import { parsePrePushInput, serializePrePushUpdates } from '../signing/signing-core.mjs';
import { runNodeScript } from './shared.mjs';
+let input = '';
+process.stdin.setEncoding('utf8');
+for await (const chunk of process.stdin) input += chunk;
+try {
+ const updates = parsePrePushInput(input);
+ process.env.WORLD_SCRIPT_PREPUSH_UPDATES = serializePrePushUpdates(updates);
+} catch (error) {
+ console.error(
+ `pre-push evidence capture failed closed: ${error instanceof Error ? error.message : 'invalid input'}`,
+ );
+ process.exit(1);
+}
+
if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0)
process.exit(1);
process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs'));
diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts
index 715a9d42c..fb232da67 100644
--- a/scripts/signing/signing-core.d.mts
+++ b/scripts/signing/signing-core.d.mts
@@ -71,6 +71,27 @@ export function getSigningConfig(cwd?: string): SigningConfig;
export function hasCommitSignature(commitText: string): boolean;
export function isGitHubCompatibleEmail(email: string): boolean;
export function parseRefUpdate(line: string): RefUpdate | null;
+export function parsePrePushInput(input: string): RefUpdate[];
+export function serializePrePushUpdates(updates: RefUpdate[]): string;
+export function parseSerializedPrePushUpdates(serialized: string): RefUpdate[];
+export interface PushEvidenceUpdate extends RefUpdate {
+ base?: string;
+ disposition: 'DELETED' | 'TAG' | 'NEW_BRANCH' | 'UPDATED';
+}
+export interface PushEvidence {
+ updates: PushEvidenceUpdate[];
+ changedFiles: string[];
+ evidenceState: 'RESOLVED' | 'INVALID';
+ reason?: string;
+}
+export function resolvePushEvidence(
+ input: string | string[] | RefUpdate[],
+ cwd?: string,
+ dependencies?: {
+ commitExists?: (sha: string) => boolean;
+ changedFilesBetween?: (base: string, head: string) => string[];
+ },
+): PushEvidence;
export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[];
export function runSigningProbe(cwd?: string): { ok: boolean; reason?: string; commit?: string };
export function verifyCommitObject(sha: string, cwd?: string): VerificationResult;
@@ -92,7 +113,10 @@ export function pushCommitShas(
export function parseAnnotatedTag(
sha: string,
cwd?: string,
-): { objectType: 'commit'; target: string } | { objectType: 'tag'; target: string; targetType: string } | null;
+):
+ | { objectType: 'commit'; target: string }
+ | { objectType: 'tag'; target: string; targetType: string }
+ | null;
export function verifyTagObject(sha: string, cwd?: string): VerificationResult;
export function remoteTrackingBases(remote: string, remoteRef: string, cwd?: string): string[];
export function outgoingBaseShas(update: RefUpdate, fallbackBases: string[]): string[];
@@ -111,7 +135,7 @@ export function safeConfigSummary(cwd?: string): {
unsafeOverrides: string[];
};
export function verifyOutgoingUpdates(
- lines: string[],
+ input: string | string[] | RefUpdate[],
remote: string,
cwd?: string,
dependencies?: {
diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs
index 64b28b5b8..55dc244d4 100644
--- a/scripts/signing/signing-core.mjs
+++ b/scripts/signing/signing-core.mjs
@@ -245,6 +245,103 @@ export function parseRefUpdate(line) {
return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] };
}
+// QNBS-v3: parse the hook stream once so signing and admission consume identical push evidence.
+export function parsePrePushInput(input) {
+ if (typeof input !== 'string') throw new Error('pre-push input must be text');
+ const lines = input.split(/\r?\n/).filter((line) => line.length > 0);
+ if (lines.length === 0) throw new Error('pre-push input is empty');
+ const updates = lines.map(parseRefUpdate);
+ if (updates.some((update) => !update)) throw new Error('invalid pre-push ref-update input');
+ return updates;
+}
+
+export function serializePrePushUpdates(updates) {
+ if (!Array.isArray(updates) || updates.length === 0)
+ throw new Error('pre-push updates are empty');
+ const lines = updates.map((update) => {
+ const fields = [update.localRef, update.localSha, update.remoteRef, update.remoteSha];
+ if (fields.some((field) => typeof field !== 'string' || field.length === 0))
+ throw new Error('pre-push update contains an invalid field');
+ return fields.join(' ');
+ });
+ return JSON.stringify(lines);
+}
+
+export function parseSerializedPrePushUpdates(serialized) {
+ if (typeof serialized !== 'string' || serialized.length === 0)
+ throw new Error('serialized pre-push input is missing');
+ let lines;
+ try {
+ lines = JSON.parse(serialized);
+ } catch {
+ throw new Error('serialized pre-push input is not valid JSON');
+ }
+ if (!Array.isArray(lines) || lines.some((line) => typeof line !== 'string'))
+ throw new Error('serialized pre-push input must contain text lines');
+ return parsePrePushInput(lines.join('\n'));
+}
+
+function changedFilesBetween(base, head, cwd) {
+ const result = runGit(['diff', '--no-renames', '--name-only', '-z', base, head, '--'], { cwd });
+ if (result.status !== 0) throw new Error('cannot resolve changed paths for outgoing ref');
+ return result.stdout.split('\0').filter((path) => path.length > 0);
+}
+
+export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {}) {
+ try {
+ const updates = Array.isArray(input)
+ ? input.every((item) => typeof item === 'string')
+ ? parsePrePushInput(input.join('\n'))
+ : input
+ : parsePrePushInput(input);
+ if (updates.length === 0 || updates.some((update) => !update))
+ throw new Error('pre-push updates are empty or invalid');
+ const commitExists =
+ dependencies.commitExists ??
+ ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{commit}`], { cwd }).status === 0);
+ const resolveFiles =
+ dependencies.changedFilesBetween ?? ((base, head) => changedFilesBetween(base, head, cwd));
+ const changedFiles = new Set();
+ const evidenceUpdates = [];
+ for (const update of updates) {
+ if (
+ (!isSha(update.localSha) && !isZeroSha(update.localSha)) ||
+ (!isSha(update.remoteSha) && !isZeroSha(update.remoteSha))
+ )
+ throw new Error(`invalid SHA in update for ${update.remoteRef}`);
+ if (isZeroSha(update.localSha)) {
+ evidenceUpdates.push({ ...update, disposition: 'DELETED' });
+ continue;
+ }
+ if (!commitExists(update.localSha))
+ throw new Error(`local outgoing object is unavailable for ${update.localRef}`);
+ if (update.remoteRef.startsWith('refs/tags/')) {
+ evidenceUpdates.push({ ...update, disposition: 'TAG' });
+ continue;
+ }
+ if (!update.remoteRef.startsWith('refs/heads/'))
+ throw new Error(`unsupported outgoing ref ${update.remoteRef}`);
+ const base = isZeroSha(update.remoteSha) ? EMPTY_TREE : update.remoteSha;
+ if (!isZeroSha(base) && base !== EMPTY_TREE && !commitExists(base))
+ throw new Error(`remote base object is unavailable for ${update.remoteRef}`);
+ for (const path of resolveFiles(base, update.localSha)) changedFiles.add(path);
+ evidenceUpdates.push({
+ ...update,
+ base,
+ disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED',
+ });
+ }
+ return { updates: evidenceUpdates, changedFiles: [...changedFiles], evidenceState: 'RESOLVED' };
+ } catch (error) {
+ return {
+ updates: [],
+ changedFiles: [],
+ evidenceState: 'INVALID',
+ reason: error instanceof Error ? error.message : 'invalid push evidence',
+ };
+ }
+}
+
function refSha(ref, cwd) {
const sha = gitOutput(['rev-parse', '--verify', `${ref}^{commit}`], { cwd });
return isSha(sha) ? sha : null;
@@ -339,14 +436,25 @@ export function classifyTagVerification({
: commitVerification;
}
-export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd(), dependencies = {}) {
+export function verifyOutgoingUpdates(input, remote, cwd = process.cwd(), dependencies = {}) {
const verifyCommit = dependencies.verifyCommitObject ?? ((sha) => verifyCommitObject(sha, cwd));
const verifyTag = dependencies.verifyTagObject ?? ((sha) => verifyTagObject(sha, cwd));
const getIntroducedCommits =
dependencies.introducedCommits ?? ((update) => introducedCommits(update, remote, cwd));
- const updates = lines.map(parseRefUpdate);
- if (updates.some((update) => !update))
- return { ok: false, reason: 'invalid pre-push ref-update input' };
+ let updates;
+ try {
+ updates =
+ Array.isArray(input) && input.every((item) => typeof item === 'string')
+ ? parsePrePushInput(input.join('\n'))
+ : input;
+ if (!Array.isArray(updates) || updates.length === 0 || updates.some((update) => !update))
+ throw new Error('invalid pre-push ref-update input');
+ } catch (error) {
+ return {
+ ok: false,
+ reason: error instanceof Error ? error.message : 'invalid pre-push ref-update input',
+ };
+ }
const reports = [];
for (const update of updates) {
if (isZeroSha(update.localSha)) continue;
diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs
index a499b9eca..45d23e795 100644
--- a/scripts/signing/verify-outgoing.mjs
+++ b/scripts/signing/verify-outgoing.mjs
@@ -1,6 +1,10 @@
#!/usr/bin/env node
import process from 'node:process';
-import { verifyOutgoingUpdates } from './signing-core.mjs';
+import {
+ parsePrePushInput,
+ parseSerializedPrePushUpdates,
+ verifyOutgoingUpdates,
+} from './signing-core.mjs';
const remote = process.argv[2];
if (!remote) {
@@ -9,12 +13,17 @@ if (!remote) {
);
process.exit(1);
}
-let input = '';
-process.stdin.setEncoding('utf8');
-for await (const chunk of process.stdin) input += chunk;
-const lines = input.split(/\r?\n/).filter(Boolean);
try {
- const result = verifyOutgoingUpdates(lines, remote);
+ let updates;
+ const serialized = process.env.WORLD_SCRIPT_PREPUSH_UPDATES;
+ if (serialized) updates = parseSerializedPrePushUpdates(serialized);
+ else {
+ let input = '';
+ process.stdin.setEncoding('utf8');
+ for await (const chunk of process.stdin) input += chunk;
+ updates = parsePrePushInput(input);
+ }
+ const result = verifyOutgoingUpdates(updates, remote);
if (!result.ok) {
console.error(`pre-push signing check rejected the update: ${result.reason}`);
process.exit(1);
diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts
index c14f30c7c..96a2c7f94 100644
--- a/tests/unit/signing.test.ts
+++ b/tests/unit/signing.test.ts
@@ -6,10 +6,14 @@ import {
hasCommitSignature,
isGitHubCompatibleEmail,
outgoingBaseShas,
+ parsePrePushInput,
parseRefUpdate,
+ parseSerializedPrePushUpdates,
pushCommitShas,
pushEventRange,
+ resolvePushEvidence,
selectIntroducedCommits,
+ serializePrePushUpdates,
verifyOutgoingUpdates,
} from '../../scripts/signing/signing-core.mjs';
import {
@@ -41,6 +45,63 @@ describe('local signing controls', () => {
expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toBeNull();
});
+ it('round-trips one canonical structured update stream for both consumers', () => {
+ const updates = parsePrePushInput(
+ `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}\n`,
+ );
+ expect(parseSerializedPrePushUpdates(serializePrePushUpdates(updates))).toEqual(updates);
+ });
+
+ it('resolves committed paths independently of a clean or dirty worktree', () => {
+ const update = parseRefUpdate(
+ `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`,
+ )!;
+ const result = resolvePushEvidence([update], process.cwd(), {
+ commitExists: () => true,
+ changedFilesBetween: () => ['src/with\nnewline.ts', '世界 file.ts', 'with\t tab.ts'],
+ });
+ expect(result).toMatchObject({ evidenceState: 'RESOLVED' });
+ expect(result.changedFiles).toEqual(['src/with\nnewline.ts', '世界 file.ts', 'with\t tab.ts']);
+ });
+
+ it('handles new branches, deletions, multiple refs, and invalid input explicitly', () => {
+ const zero = '0'.repeat(40);
+ const branch = parseRefUpdate(`refs/heads/new ${'a'.repeat(40)} refs/heads/new ${zero}`)!;
+ const deletion = parseRefUpdate(`refs/heads/old ${zero} refs/heads/old ${'b'.repeat(40)}`)!;
+ const result = resolvePushEvidence([branch, deletion], process.cwd(), {
+ commitExists: (sha) => sha !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
+ changedFilesBetween: (base) =>
+ base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' ? ['new.ts'] : [],
+ });
+ expect(result.evidenceState).toBe('RESOLVED');
+ expect(result.updates.map(({ disposition }) => disposition)).toEqual(['NEW_BRANCH', 'DELETED']);
+ expect(result.changedFiles).toEqual(['new.ts']);
+ expect(resolvePushEvidence('malformed').evidenceState).toBe('INVALID');
+ const localSha = 'a'.repeat(40);
+ const line = `refs/heads/new ${localSha} refs/heads/new ${zero}`;
+ const legacyResult = resolvePushEvidence([line], process.cwd(), {
+ commitExists: (sha) => sha !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
+ changedFilesBetween: (base, head) =>
+ base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904' && head === localSha ? ['new.ts'] : [],
+ });
+ expect(legacyResult).toMatchObject({ evidenceState: 'RESOLVED', changedFiles: ['new.ts'] });
+ });
+
+ it('fails closed for missing objects and Git path-resolution failures', () => {
+ const update = parseRefUpdate(
+ `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`,
+ )!;
+ expect(resolvePushEvidence([update]).evidenceState).toBe('INVALID');
+ expect(
+ resolvePushEvidence([update], process.cwd(), {
+ commitExists: () => true,
+ changedFilesBetween: () => {
+ throw new Error('git diff failed');
+ },
+ }).evidenceState,
+ ).toBe('INVALID');
+ });
+
it('checks only commits introduced beyond the remote tracking base', () => {
const base = 'a'.repeat(40);
const introduced = 'b'.repeat(40);