Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-6954%2B_%2F_575_files-22C55E" alt="6954+ tests / 575 files">
<img src="https://img.shields.io/badge/Tests-6959%2B_%2F_575_files-22C55E" alt="6959+ tests / 575 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 16 additions & 0 deletions scripts/ci-prepush-lowend.mjs
Original file line number Diff line number Diff line change
@@ -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'])],
Expand Down
49 changes: 46 additions & 3 deletions scripts/hooks/pre-push.mjs
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
qnbs marked this conversation as resolved.
34 changes: 32 additions & 2 deletions scripts/signing/signing-core.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
Expand All @@ -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?: {
Expand Down
184 changes: 160 additions & 24 deletions scripts/signing/signing-core.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
Comment thread
qnbs marked this conversation as resolved.
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);
Comment thread
qnbs marked this conversation as resolved.
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;
Expand Down Expand Up @@ -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}` };
Comment thread
qnbs marked this conversation as resolved.
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()) {
Expand Down
Loading
Loading