Skip to content
Open
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-6958%2B_%2F_575_files-22C55E" alt="6958+ 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 (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` |
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 (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
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
- **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)

Expand Down
19 changes: 19 additions & 0 deletions scripts/ci-prepush-lowend.mjs
Original file line number Diff line number Diff line change
@@ -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'])],
Expand Down
14 changes: 14 additions & 0 deletions scripts/hooks/pre-push.mjs
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Storing the complete update stream in WORLD_SCRIPT_PREPUSH_UPDATES puts the serialized data into the child processes' inherited environment. Large multi-ref pushes can exceed the operating system's environment/argument-size limit, causing spawnSync in runNodeScript to fail and rejecting an otherwise valid push. Use a bounded file or pipe-based handoff instead of environment transport. [possible bug]

Severity Level: Major ⚠️
- ❌ Large multi-ref pushes are rejected locally.
- ⚠️ Failure occurs before signing and admission checks run.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** scripts/hooks/pre-push.mjs
**Line:** 10:10
**Comment:**
	*Possible Bug: Storing the complete update stream in `WORLD_SCRIPT_PREPUSH_UPDATES` puts the serialized data into the child processes' inherited environment. Large multi-ref pushes can exceed the operating system's environment/argument-size limit, causing `spawnSync` in `runNodeScript` to fail and rejecting an otherwise valid push. Use a bounded file or pipe-based handoff instead of environment transport.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

} 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'));
28 changes: 26 additions & 2 deletions scripts/signing/signing-core.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
Expand All @@ -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?: {
Expand Down
116 changes: 112 additions & 4 deletions scripts/signing/signing-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment on lines +251 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept empty pre-push streams for no-op pushes

When git push finds every requested ref already up to date, Git still invokes the pre-push hook but supplies an empty stdin stream (confirmed with Git 2.43 against a local bare remote). This new check therefore throws pre-push input is empty, turns an otherwise successful no-op push into a failure, and exits before the repository's mandatory quick pre-push gate runs; treat zero updates as a valid stream with no objects to verify.

AGENTS.md reference: AGENTS.md:L297-L303

Useful? React with 👍 / 👎.

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;
Expand Down Expand Up @@ -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;
Comment on lines +446 to +449

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse documented raw-string verification inputs

The updated declaration permits verifyOutgoingUpdates(string), but this branch parses only arrays whose elements are strings and otherwise assigns the raw value directly to updates. A typed caller passing one canonical pre-push stream is therefore rejected by the following Array.isArray check even though wrapping the same line in an array succeeds; route raw strings through parsePrePushInput as well.

Useful? React with 👍 / 👎.

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;
Expand Down
21 changes: 15 additions & 6 deletions scripts/signing/verify-outgoing.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/signing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ import {
hasCommitSignature,
isGitHubCompatibleEmail,
outgoingBaseShas,
parsePrePushInput,
parseRefUpdate,
parseSerializedPrePushUpdates,
pushCommitShas,
pushEventRange,
resolvePushEvidence,
selectIntroducedCommits,
serializePrePushUpdates,
verifyOutgoingUpdates,
} from '../../scripts/signing/signing-core.mjs';
import {
Expand Down Expand Up @@ -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);
Expand Down
Loading