Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
180 changes: 155 additions & 25 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,129 @@ 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'
);
}

// 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: normalizePrePushUpdates(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 = normalizePrePushUpdates(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 (
(!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;
}
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 (!update.remoteRef.startsWith('refs/heads/'))
throw new Error(`unsupported outgoing ref ${update.remoteRef}`);
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 +462,43 @@ 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}` };
let updates;
try {
updates = normalizePrePushUpdates(input);
const reports = [];
for (const update of updates) {
if (isZeroSha(update.localSha)) continue;
if (!isSha(update.localSha))
return { ok: false, reports, 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}` };
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