diff --git a/README.md b/README.md
index ceaf3c849..fe5022aef 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 (6959+ 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 (6959+ 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
+- **6959+ 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..16c7c5b2c 100644
--- a/scripts/ci-prepush-lowend.mjs
+++ b/scripts/ci-prepush-lowend.mjs
@@ -1,5 +1,21 @@
import process from 'node:process';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './hooks/shared.mjs';
+import { readPrePushEvidenceFile, resolvePushEvidence } from './signing/signing-core.mjs';
+
+const evidenceIndex = process.argv.indexOf('--prepush-evidence-file');
+if (evidenceIndex >= 0) {
+ try {
+ const evidence = resolvePushEvidence(
+ readPrePushEvidenceFile(process.argv[evidenceIndex + 1]),
+ process.cwd(),
+ );
+ if (evidence.evidenceState !== 'RESOLVED')
+ throw new Error(evidence.reason ?? 'invalid evidence');
+ } catch (error) {
+ console.error(`[local-lowend] outgoing evidence rejected: ${error.message}`);
+ process.exit(1);
+ }
+}
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..c97c0a1b5 100644
--- a/scripts/hooks/pre-push.mjs
+++ b/scripts/hooks/pre-push.mjs
@@ -1,6 +1,49 @@
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import process from 'node:process';
+import { normalizePrePushUpdates, writePrePushEvidenceFile } from '../signing/signing-core.mjs';
import { runNodeScript } from './shared.mjs';
-if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0)
- process.exit(1);
-process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs'));
+let input = '';
+process.stdin.setEncoding('utf8');
+for await (const chunk of process.stdin) input += chunk;
+
+let evidenceDir;
+let evidenceFile;
+let exitCode = 1;
+try {
+ const updates = normalizePrePushUpdates(input);
+ evidenceDir = await mkdtemp(join(tmpdir(), 'worldscript-prepush-'));
+ 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);
+} catch (error) {
+ console.error(
+ `pre-push evidence capture failed closed: ${error instanceof Error ? error.message : 'invalid input'}`,
+ );
+} finally {
+ if (evidenceFile) {
+ try {
+ await rm(evidenceFile, { force: true });
+ } catch (error) {
+ console.error(
+ `pre-push evidence cleanup failed: ${error instanceof Error ? error.message : 'unknown error'}`,
+ );
+ if (exitCode === 0) exitCode = 1;
+ }
+ }
+ if (evidenceDir) {
+ try {
+ await rm(evidenceDir, { recursive: true, force: true });
+ } catch (error) {
+ console.error(
+ `pre-push evidence directory cleanup failed: ${error instanceof Error ? error.message : 'unknown error'}`,
+ );
+ if (exitCode === 0) exitCode = 1;
+ }
+ }
+}
+process.exit(exitCode);
diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts
index 715a9d42c..8ccec839d 100644
--- a/scripts/signing/signing-core.d.mts
+++ b/scripts/signing/signing-core.d.mts
@@ -71,6 +71,33 @@ 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 normalizePrePushUpdates(input: string | string[] | RefUpdate[]): RefUpdate[];
+export function serializePrePushEvidence(input: string | string[] | RefUpdate[]): string;
+export function parsePrePushEvidence(serialized: string): RefUpdate[];
+export function readPrePushEvidenceFile(file: string): RefUpdate[];
+export function writePrePushEvidenceFile(
+ file: string,
+ input: string | string[] | RefUpdate[],
+): void;
+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;
+ objectExists?: (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 +119,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 +141,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..7122ed82b 100644
--- a/scripts/signing/signing-core.mjs
+++ b/scripts/signing/signing-core.mjs
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process';
-import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
@@ -245,6 +245,138 @@ export function parseRefUpdate(line) {
return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] };
}
+function isRefUpdate(value) {
+ return (
+ value &&
+ typeof value === 'object' &&
+ typeof value.localRef === 'string' &&
+ typeof value.localSha === 'string' &&
+ typeof value.remoteRef === 'string' &&
+ typeof value.remoteSha === 'string'
+ );
+}
+
+function validatePrePushUpdate(update) {
+ if (!isRefUpdate(update)) throw new Error('pre-push update array contains an invalid record');
+ if (
+ (!isSha(update.localSha) && !isZeroSha(update.localSha)) ||
+ (!isSha(update.remoteSha) && !isZeroSha(update.remoteSha))
+ )
+ throw new Error(`invalid SHA in update for ${update.remoteRef}`);
+ if (!update.remoteRef.startsWith('refs/heads/') && !update.remoteRef.startsWith('refs/tags/'))
+ throw new Error(`unsupported outgoing ref ${update.remoteRef}`);
+ return update;
+}
+
+function validatedPrePushUpdates(input) {
+ return normalizePrePushUpdates(input).map(validatePrePushUpdate);
+}
+
+// QNBS-v3: normalize every public input form through one fail-closed parser.
+export function normalizePrePushUpdates(input) {
+ if (typeof input === 'string') {
+ if (input === '') return [];
+ const lines = input.split(/\r?\n/).filter((line) => line.length > 0);
+ if (lines.length === 0) throw new Error('invalid pre-push ref-update input');
+ const updates = lines.map(parseRefUpdate);
+ if (updates.some((update) => !update)) throw new Error('invalid pre-push ref-update input');
+ return updates;
+ }
+ if (!Array.isArray(input)) throw new Error('pre-push input must be text or an update array');
+ if (input.length === 0) return [];
+ if (input.every((item) => typeof item === 'string'))
+ return normalizePrePushUpdates(input.join('\n'));
+ if (input.every(isRefUpdate)) return input;
+ throw new Error('pre-push update array contains an invalid record');
+}
+
+export function serializePrePushEvidence(input) {
+ return JSON.stringify({ version: 1, updates: validatedPrePushUpdates(input) });
+}
+
+export function parsePrePushEvidence(serialized) {
+ if (typeof serialized !== 'string' || serialized.length === 0)
+ throw new Error('pre-push evidence artifact is empty');
+ let document;
+ try {
+ document = JSON.parse(serialized);
+ } catch {
+ throw new Error('pre-push evidence artifact is not valid JSON');
+ }
+ if (document?.version !== 1 || !Array.isArray(document.updates))
+ throw new Error('pre-push evidence artifact has an unsupported shape');
+ return normalizePrePushUpdates(document.updates);
+}
+
+export function readPrePushEvidenceFile(file) {
+ if (typeof file !== 'string' || file.length === 0)
+ throw new Error('pre-push evidence file is missing');
+ return parsePrePushEvidence(readFileSync(file, 'utf8'));
+}
+
+export function writePrePushEvidenceFile(file, input) {
+ if (typeof file !== 'string' || file.length === 0)
+ throw new Error('pre-push evidence file is missing');
+ writeFileSync(file, serializePrePushEvidence(input), {
+ encoding: 'utf8',
+ mode: 0o600,
+ flag: 'wx',
+ });
+}
+
+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 = validatedPrePushUpdates(input);
+ const commitExists =
+ dependencies.commitExists ??
+ ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{commit}`], { cwd }).status === 0);
+ const objectExists =
+ dependencies.objectExists ??
+ ((sha) => isSha(sha) && runGit(['cat-file', '-e', `${sha}^{object}`], { 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 (isZeroSha(update.localSha)) {
+ evidenceUpdates.push({ ...update, disposition: 'DELETED' });
+ continue;
+ }
+ if (update.remoteRef.startsWith('refs/tags/')) {
+ if (!objectExists(update.localSha))
+ throw new Error(`local outgoing object is unavailable for ${update.localRef}`);
+ evidenceUpdates.push({ ...update, disposition: 'TAG' });
+ continue;
+ }
+ if (!commitExists(update.localSha))
+ throw new Error(`local outgoing commit is unavailable for ${update.localRef}`);
+ const base = isZeroSha(update.remoteSha) ? EMPTY_TREE : update.remoteSha;
+ if (base !== EMPTY_TREE && !commitExists(base))
+ throw new Error(`remote base commit 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,36 +471,40 @@ 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' };
const reports = [];
- for (const update of updates) {
- if (isZeroSha(update.localSha)) continue;
- if (!isSha(update.localSha))
- return { ok: false, reason: `invalid outgoing SHA for ${update.remoteRef}` };
- if (update.remoteRef.startsWith('refs/tags/')) {
- const verification = verifyTag(update.localSha);
- reports.push({ sha: update.localSha, subject: update.remoteRef, verification });
- if (!verification.ok)
- return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` };
- continue;
- }
- const commits = getIntroducedCommits(update);
- for (const sha of commits) {
- const verification = verifyCommit(sha);
- const report = { sha, subject: commitSubject(sha, cwd), verification };
- reports.push(report);
- if (!verification.ok)
- return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` };
+ try {
+ const updates = validatedPrePushUpdates(input);
+ for (const update of updates) {
+ if (isZeroSha(update.localSha)) continue;
+ if (update.remoteRef.startsWith('refs/tags/')) {
+ const verification = verifyTag(update.localSha);
+ reports.push({ sha: update.localSha, subject: update.remoteRef, verification });
+ if (!verification.ok)
+ return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` };
+ continue;
+ }
+ const commits = getIntroducedCommits(update);
+ for (const sha of commits) {
+ const verification = verifyCommit(sha);
+ const report = { sha, subject: commitSubject(sha, cwd), verification };
+ reports.push(report);
+ if (!verification.ok)
+ return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` };
+ }
}
+ return { ok: true, reports };
+ } catch (error) {
+ return {
+ ok: false,
+ reports,
+ reason: error instanceof Error ? error.message : 'invalid pre-push ref-update input',
+ };
}
- return { ok: true, reports };
}
export function safeConfigSummary(cwd = process.cwd()) {
diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs
index a499b9eca..7d3946281 100644
--- a/scripts/signing/verify-outgoing.mjs
+++ b/scripts/signing/verify-outgoing.mjs
@@ -1,20 +1,27 @@
#!/usr/bin/env node
import process from 'node:process';
-import { verifyOutgoingUpdates } from './signing-core.mjs';
+import { readPrePushEvidenceFile, verifyOutgoingUpdates } from './signing-core.mjs';
-const remote = process.argv[2];
+const args = process.argv.slice(2);
+const remote = args[0];
+const evidenceIndex = args.indexOf('--prepush-evidence-file');
+const evidenceFile = evidenceIndex >= 0 ? args[evidenceIndex + 1] : null;
if (!remote) {
console.error(
'pre-push signing check requires the remote name. Run "pnpm run hooks:install" to refresh the installed hook.',
);
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 input;
+ if (evidenceIndex >= 0) input = readPrePushEvidenceFile(evidenceFile);
+ else {
+ let stream = '';
+ process.stdin.setEncoding('utf8');
+ for await (const chunk of process.stdin) stream += chunk;
+ input = stream;
+ }
+ const result = verifyOutgoingUpdates(input, 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..18c0d3a03 100644
--- a/tests/unit/signing.test.ts
+++ b/tests/unit/signing.test.ts
@@ -1,3 +1,6 @@
+import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
classifyCommitObject,
@@ -5,12 +8,18 @@ import {
getSigningConfig,
hasCommitSignature,
isGitHubCompatibleEmail,
+ normalizePrePushUpdates,
outgoingBaseShas,
+ parsePrePushEvidence,
parseRefUpdate,
pushCommitShas,
pushEventRange,
+ readPrePushEvidenceFile,
+ resolvePushEvidence,
selectIntroducedCommits,
+ serializePrePushEvidence,
verifyOutgoingUpdates,
+ writePrePushEvidenceFile,
} from '../../scripts/signing/signing-core.mjs';
import {
hasCompleteCommitRange,
@@ -107,6 +116,54 @@ describe('local signing controls', () => {
},
),
).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.0' }] });
+ expect(
+ verifyOutgoingUpdates(
+ [`refs/tags/v1.0.1 ${commit} refs/tags/v1.0.1 ${remote}`],
+ 'origin',
+ process.cwd(),
+ { verifyTagObject: () => ({ ok: true, reason: 'tag and target commit verified' }) },
+ ),
+ ).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.1' }] });
+ let tagVerificationCalled = false;
+ expect(
+ verifyOutgoingUpdates(
+ [
+ {
+ localRef: 'refs/tags/v1.0.2',
+ localSha: commit,
+ remoteRef: 'refs/tags/v1.0.2',
+ remoteSha: 'invalid-remote-sha',
+ },
+ ],
+ 'origin',
+ process.cwd(),
+ {
+ verifyTagObject: () => {
+ tagVerificationCalled = true;
+ return { ok: true, reason: 'tag and target commit verified' };
+ },
+ },
+ ),
+ ).toMatchObject({ ok: false });
+ expect(tagVerificationCalled).toBe(false);
+ expect(
+ verifyOutgoingUpdates(
+ [
+ {
+ localRef: 'refs/heads/main',
+ localSha: commit,
+ remoteRef: 'refs/heads/main',
+ remoteSha: 'invalid-remote-sha',
+ },
+ ],
+ 'origin',
+ process.cwd(),
+ {
+ introducedCommits: () => [commit],
+ verifyCommitObject: () => ({ ok: true, reason: 'Git-native signature verified' }),
+ },
+ ),
+ ).toMatchObject({ ok: false });
expect(
verifyOutgoingUpdates(
[`refs/heads/main ${commit} refs/heads/main ${remote}`],
@@ -123,6 +180,132 @@ describe('local signing controls', () => {
).toMatchObject({ ok: true, reports: [{ sha: commit }] });
});
+ // QNBS-v3: lock the pre-push evidence contract against malformed or unresolved input.
+ it('normalizes raw, line-array, structured, and empty public inputs', () => {
+ const zero = '0'.repeat(40);
+ const line = `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`;
+ const structured = parseRefUpdate(line)!;
+ expect(normalizePrePushUpdates('')).toEqual([]);
+ expect(normalizePrePushUpdates(line)).toEqual([structured]);
+ expect(normalizePrePushUpdates([line])).toEqual([structured]);
+ expect(normalizePrePushUpdates([structured])).toEqual([structured]);
+ expect(
+ verifyOutgoingUpdates('', 'origin', process.cwd(), {
+ introducedCommits: () => {
+ throw new Error('no-op must not enumerate commits');
+ },
+ }),
+ ).toMatchObject({ ok: true, reports: [] });
+ expect(
+ normalizePrePushUpdates(`refs/heads/deleted ${zero} refs/heads/deleted ${'c'.repeat(40)}`),
+ ).toHaveLength(1);
+ expect(() => normalizePrePushUpdates('malformed')).toThrow('invalid pre-push ref-update input');
+ expect(() => normalizePrePushUpdates('\n')).toThrow('invalid pre-push ref-update input');
+ });
+
+ it('resolves branch, new-branch, deletion, tag, and multi-ref evidence losslessly', () => {
+ const zero = '0'.repeat(40);
+ const updates = [
+ parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`)!,
+ parseRefUpdate(`refs/heads/new ${'c'.repeat(40)} refs/heads/new ${zero}`)!,
+ parseRefUpdate(`refs/heads/deleted ${zero} refs/heads/deleted ${'d'.repeat(40)}`)!,
+ parseRefUpdate(`refs/tags/v1 ${'e'.repeat(40)} refs/tags/v1 ${zero}`)!,
+ ];
+ const result = resolvePushEvidence(updates, process.cwd(), {
+ commitExists: (sha) => sha !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
+ objectExists: () => true,
+ changedFilesBetween: (base) =>
+ base === '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
+ ? ['new\nfile.ts']
+ : ['src/with\t tab.ts', '世界 file.ts', 'src/with\t tab.ts'],
+ });
+ expect(result.evidenceState).toBe('RESOLVED');
+ expect(result.updates.map(({ disposition }) => disposition)).toEqual([
+ 'UPDATED',
+ 'NEW_BRANCH',
+ 'DELETED',
+ 'TAG',
+ ]);
+ expect(result.changedFiles).toEqual(['src/with\t tab.ts', '世界 file.ts', 'new\nfile.ts']);
+ });
+
+ it('fails closed for missing objects, bases, Git failures, and unsupported evidence', () => {
+ 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: (sha) => sha === 'a'.repeat(40),
+ }).evidenceState,
+ ).toBe('INVALID');
+ expect(
+ resolvePushEvidence([update], process.cwd(), {
+ commitExists: () => true,
+ changedFilesBetween: () => {
+ throw new Error('git diff failed');
+ },
+ }).evidenceState,
+ ).toBe('INVALID');
+ expect(
+ resolvePushEvidence(
+ [
+ parseRefUpdate(
+ `refs/remotes/origin/x ${'a'.repeat(40)} refs/remotes/origin/x ${'b'.repeat(40)}`,
+ )!,
+ ],
+ process.cwd(),
+ { commitExists: () => true },
+ ).evidenceState,
+ ).toBe('INVALID');
+ expect(
+ resolvePushEvidence([
+ parseRefUpdate(
+ `refs/remotes/origin/x ${'0'.repeat(40)} refs/remotes/origin/x ${'b'.repeat(40)}`,
+ )!,
+ ]).evidenceState,
+ ).toBe('INVALID');
+ });
+
+ it('round-trips the same immutable artifact for both consumers', () => {
+ let dir: string;
+ try {
+ dir = mkdtempSync(join(tmpdir(), 'worldscript-s3a-test-'));
+ } catch (error) {
+ if (!(error instanceof Error) || !error.message.includes('read-only file system'))
+ throw error;
+ dir = mkdtempSync(join(process.cwd(), '.worldscript-s3a-test-'));
+ }
+ const file = join(dir, 'evidence.json');
+ const line = `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`;
+ try {
+ writePrePushEvidenceFile(file, [line]);
+ expect(statSync(file).mode & 0o777).toBe(0o600);
+ expect(() => writePrePushEvidenceFile(file, [line])).toThrow();
+ const serialized = readFileSync(file, 'utf8');
+ expect(parsePrePushEvidence(serialized)).toEqual(normalizePrePushUpdates([line]));
+ expect(readPrePushEvidenceFile(file)).toEqual(normalizePrePushUpdates([line]));
+ expect(() => readPrePushEvidenceFile(join(dir, 'missing.json'))).toThrow();
+ writeFileSync(file, '{"version":1,"updates":', { flag: 'w' });
+ expect(() => readPrePushEvidenceFile(file)).toThrow('not valid JSON');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ expect(() => readPrePushEvidenceFile(file)).toThrow();
+ });
+
+ it('keeps large multi-ref evidence in the artifact contract, not process environment', () => {
+ const updates = Array.from({ length: 200 }, (_, index) => ({
+ localRef: `refs/heads/feature-${index}`,
+ localSha: index.toString(16).padStart(40, '0'),
+ remoteRef: `refs/heads/feature-${index}`,
+ remoteSha: '0'.repeat(40),
+ }));
+ const artifact = serializePrePushEvidence(updates);
+ expect(artifact.length).toBeGreaterThan(10_000);
+ expect(parsePrePushEvidence(artifact)).toEqual(updates);
+ });
+
it('derives an exact push range and rejects an unverified earlier commit', () => {
const before = '0'.repeat(40);
const after = 'f'.repeat(40);