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-6998%2B_%2F_578_files-22C55E" alt="6998+ tests / 578 files">
<img src="https://img.shields.io/badge/Tests-7005%2B_%2F_578_files-22C55E" alt="7005+ tests / 578 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 (6998+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7005+ tests / 578 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 (6998+ tests, 578 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7005+ tests, 578 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):**
- **6998+ unit tests** across **578 test files** — CI is authoritative for pass/fail
- **7005+ unit tests** across **578 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
13 changes: 13 additions & 0 deletions scripts/ci-prepush-lowend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ async function main() {
'UNKNOWN',
'could not determine whether local results reflect the exact pushed commit(s); required CI remains authoritative',
);
// QNBS-v3: informational only — compares the pushed commit's manifests to the local reconciled baseline.
if (manualEvidence.dependencyState === 'DIVERGED')
report(
'Dependency manifests vs. push',
'DIVERGED',
'pushed commit(s) declare dependencies not yet reconciled locally; required CI remains authoritative',
);
else if (manualEvidence.dependencyState === 'UNKNOWN')
report(
'Dependency manifests vs. push',
'UNKNOWN',
'could not compare pushed dependency manifests to the local baseline; required CI remains authoritative',
);

if (!ensureDependencyState()) {
report('Dependency state', 'FAIL');
Expand Down
2 changes: 2 additions & 0 deletions scripts/ci-prepush-range-resolver.d.mts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { DependencyState } from './dependency-state.d.mts';
import type { WorkingTreeState } from './signing/signing-core.d.mts';

export interface ManualChangeEvidence {
readonly files: readonly string[];
readonly rangeResolved: boolean;
readonly workingTreeState: WorkingTreeState;
readonly dependencyState: DependencyState;
}

export interface ManualRangeDependencies {
Expand Down
16 changes: 6 additions & 10 deletions scripts/ci-prepush-range-resolver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,14 @@ export function changedFilesFromManualRange(dependencies = {}) {
const workingTreeFiles = dependencies.workingTreeFiles ?? defaultWorkingTreeFiles;

// QNBS-v3: no push event or localSha exists in this mode, so nothing is ever compared.
const notApplicable = { workingTreeState: 'NOT_APPLICABLE', dependencyState: 'NOT_APPLICABLE' };
const upstream = resolveUpstream();
if (!upstream) return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' };
if (!upstream) return { files: [], rangeResolved: false, ...notApplicable };
const diffFiles = diffNames(`${upstream}..HEAD`);
if (diffFiles === null)
return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' };
if (diffFiles === null) return { files: [], rangeResolved: false, ...notApplicable };
const workingFiles = workingTreeFiles();
if (workingFiles === null)
return { files: [], rangeResolved: false, workingTreeState: 'NOT_APPLICABLE' };
return {
files: diffFiles.concat(workingFiles),
rangeResolved: true,
workingTreeState: 'NOT_APPLICABLE',
};
if (workingFiles === null) return { files: [], rangeResolved: false, ...notApplicable };
return { files: diffFiles.concat(workingFiles), rangeResolved: true, ...notApplicable };
}

export function resolveManualEvidence(evidenceFile, dependencies = {}) {
Expand All @@ -74,5 +69,6 @@ export function resolveManualEvidence(evidenceFile, dependencies = {}) {
files: evidence.changedFiles,
rangeResolved: evidence.pathEvidenceState === 'COMPLETE',
workingTreeState: evidence.workingTreeState,
dependencyState: evidence.dependencyState,
};
}
42 changes: 42 additions & 0 deletions scripts/dependency-state.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// QNBS-v3: diagnostic-only dimension, independent of resolvePushEvidence's canonical evidence validity.
export type DependencyState = 'MATCHES' | 'DIVERGED' | 'NOT_APPLICABLE' | 'UNKNOWN';

export function dependencyFiles(root?: string): string[];
export function calculateDependencyFingerprint(root?: string): string;
export function fingerprintPath(root?: string): string;
export function readStoredFingerprint(root?: string): string | null;
export function writeStoredFingerprint(root?: string, fingerprint?: string): void;
export function verifyDependencyState(root?: string): string;
export function main(command?: string): void;

export interface DependencyFilesFromRefDependencies {
listTree?: (sha: string) => string[] | null;
}

export function dependencyFilesFromRef(
sha: string,
root?: string,
dependencies?: DependencyFilesFromRefDependencies,
): string[] | null;

export interface DependencyFingerprintFromRefDependencies extends DependencyFilesFromRefDependencies {
dependencyFilesFromRef?: (sha: string) => string[] | null;
readFileAtRef?: (relativePath: string) => Buffer | null;
}

export function calculateDependencyFingerprintFromRef(
sha: string,
root?: string,
dependencies?: DependencyFingerprintFromRefDependencies,
): string | null;

export interface ComputeDependencyStateDependencies extends DependencyFingerprintFromRefDependencies {
readStoredFingerprint?: () => string | null;
calculateDependencyFingerprintFromRef?: (sha: string) => string | null;
}

export function computeDependencyState(
sha: string,
root?: string,
dependencies?: ComputeDependencyStateDependencies,
): DependencyState;
103 changes: 98 additions & 5 deletions scripts/dependency-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ import { join, relative, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';

const projectRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
// QNBS-v3: import.meta.url isn't always a file: URL under Vitest's transform; cwd is the repo root there.
function resolveProjectRoot() {
try {
return resolve(fileURLToPath(new URL('..', import.meta.url)));
} catch {
return process.cwd();
}
}
const projectRoot = resolveProjectRoot();
const fingerprintRelativePath = 'node_modules/.worldscript-deps-fingerprint';

function walkFiles(directory) {
Expand Down Expand Up @@ -42,16 +50,101 @@ export function dependencyFiles(root = projectRoot) {
return files.filter((file) => existsSync(file)).sort();
}

export function calculateDependencyFingerprint(root = projectRoot) {
// QNBS-v3: byte-safe CRLF->LF normalization; a latin1 round-trip preserves every byte value 0-255.
function normalizeLineEndings(content) {
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
return Buffer.from(buffer.toString('latin1').replaceAll('\r\n', '\n'), 'latin1');
}

// QNBS-v3: shared by both the filesystem and git-ref fingerprint paths so they can never drift.
function hashManifests(entries) {
const hash = createHash('sha256');
for (const file of dependencyFiles(root)) {
hash.update(`${relative(root, file).replaceAll('\\', '/')}\0`);
hash.update(readFileSync(file));
for (const [relativePath, content] of [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
hash.update(`${relativePath}\0`);
// QNBS-v3: normalizes a core.autocrlf checkout vs. the LF-stored git blob to the same bytes.
hash.update(normalizeLineEndings(content));
hash.update('\0');
}
return hash.digest('hex');
}

export function calculateDependencyFingerprint(root = projectRoot) {
const entries = dependencyFiles(root).map((file) => [
relative(root, file).replaceAll('\\', '/'),
readFileSync(file),
]);
return hashManifests(entries);
}

// QNBS-v3: --full-tree ignores cwd-subdirectory scoping; -z disables git's default path C-quoting.
function defaultListTreeFiles(sha, cwd) {
const result = spawnSync('git', ['ls-tree', '-r', '--full-tree', '--name-only', '-z', sha], {
cwd,
encoding: 'utf8',
timeout: 5000,
});
if (result.error || result.status !== 0) return null;
return result.stdout.split('\0').filter(Boolean);
}

// QNBS-v3: diagnostic-only; mirrors dependencyFiles' inclusion rules against a commit, not disk.
export function dependencyFilesFromRef(sha, root = projectRoot, dependencies = {}) {
const listTree = dependencies.listTree ?? ((ref) => defaultListTreeFiles(ref, root));
const allPaths = listTree(sha);
if (allPaths === null) return null;
const rootFiles = new Set(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']);
const packagePattern = /^packages\/[^/]+\/package\.json$/;
return allPaths
.filter(
(path) => rootFiles.has(path) || path.startsWith('patches/') || packagePattern.test(path),
)
.sort();
}

// QNBS-v3: no encoding -- raw Buffer stdout, matching readFileSync's raw bytes for invalid UTF-8 safety.
function defaultReadFileAtRef(sha, relativePath, cwd) {
const result = spawnSync('git', ['show', `${sha}:${relativePath}`], {
cwd,
timeout: 5000,
maxBuffer: 16 * 1024 * 1024,
});
if (result.error || result.status !== 0) return null;
return result.stdout;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
}

// QNBS-v3: diagnostic-only; reads localSha's committed manifests via git objects, no worktree.
export function calculateDependencyFingerprintFromRef(sha, root = projectRoot, dependencies = {}) {
const listFiles = dependencies.dependencyFilesFromRef ?? (() => dependencyFilesFromRef(sha, root, dependencies));
const files = listFiles(sha);
if (files === null) return null;
const readContent = dependencies.readFileAtRef ?? ((path) => defaultReadFileAtRef(sha, path, root));
const entries = [];
for (const relativePath of files) {
const content = readContent(relativePath);
if (content === null) return null;
entries.push([relativePath, content]);
Comment thread
qnbs marked this conversation as resolved.
}
return hashManifests(entries);
}

// QNBS-v3: diagnostic-only signal; never throws, so it cannot corrupt canonical evidence validity.
export function computeDependencyState(sha, root = projectRoot, dependencies = {}) {
try {
const readStored = dependencies.readStoredFingerprint ?? (() => readStoredFingerprint(root));
const storedFingerprint = readStored();
// QNBS-v3: no baseline reconciled yet on this machine -- an honest unknown, not "no comparison".
if (!storedFingerprint) return 'UNKNOWN';
const fingerprintFromRef =
dependencies.calculateDependencyFingerprintFromRef ??
((ref) => calculateDependencyFingerprintFromRef(ref, root, dependencies));
const refFingerprint = fingerprintFromRef(sha);
if (refFingerprint === null) return 'UNKNOWN';
return refFingerprint === storedFingerprint ? 'MATCHES' : 'DIVERGED';
} catch {
return 'UNKNOWN';
}
}

export function fingerprintPath(root = projectRoot) {
return join(root, fingerprintRelativePath);
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/signing/signing-core.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,23 @@ export function writePrePushEvidenceFile(
file: string,
input: string | string[] | RefUpdate[],
): void;
import type { DependencyState } from '../dependency-state.d.mts';

// QNBS-v3: diagnostic-only dimension, independent of evidenceState/pathEvidenceState validity.
export type WorkingTreeState = 'MATCHES' | 'DIVERGED' | 'NOT_APPLICABLE' | 'UNKNOWN';
export interface PushEvidenceUpdate extends RefUpdate {
base?: string;
disposition: 'DELETED' | 'TAG' | 'NEW_BRANCH' | 'UPDATED';
workingTreeState: WorkingTreeState;
dependencyState: DependencyState;
}
export interface PushEvidence {
updates: PushEvidenceUpdate[];
changedFiles: string[];
evidenceState: 'RESOLVED' | 'INVALID';
pathEvidenceState: 'COMPLETE' | 'PARTIAL';
workingTreeState: WorkingTreeState;
dependencyState: DependencyState;
reason?: string;
}
export function computeWorkingTreeState(
Expand All @@ -107,6 +111,7 @@ export function resolvePushEvidence(
objectExists?: (sha: string) => boolean;
changedFilesBetween?: (base: string, head: string) => string[];
worktreeMatchesCommit?: (sha: string) => WorkingTreeState;
dependencyStateForRef?: (sha: string) => DependencyState;
},
): PushEvidence;
export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[];
Expand Down
38 changes: 30 additions & 8 deletions scripts/signing/signing-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { computeDependencyState } from '../dependency-state.mjs';

const SHA = /^[0-9a-f]{40}$/i;
const ZERO_SHA = /^0{40}$/;
Expand Down Expand Up @@ -346,11 +347,21 @@ export function computeWorkingTreeState(sha, cwd, dependencies = {}) {
return 'UNKNOWN';
}

function aggregateWorkingTreeState(evidenceUpdates) {
const relevant = evidenceUpdates.filter((update) => update.workingTreeState !== 'NOT_APPLICABLE');
// QNBS-v3: an injected diagnostic resolver that throws must not corrupt canonical evidence validity.
function safeDiagnostic(resolver, sha) {
try {
return resolver(sha);
} catch {
return 'UNKNOWN';
}
}

// QNBS-v3: shared by every diagnostic-only dimension so precedence can never drift between them.
function aggregateDiagnosticState(states) {
const relevant = states.filter((state) => state !== 'NOT_APPLICABLE');
if (relevant.length === 0) return 'NOT_APPLICABLE';
if (relevant.some((update) => update.workingTreeState === 'DIVERGED')) return 'DIVERGED';
if (relevant.some((update) => update.workingTreeState === 'UNKNOWN')) return 'UNKNOWN';
if (relevant.includes('DIVERGED')) return 'DIVERGED';
if (relevant.includes('UNKNOWN')) return 'UNKNOWN';
return 'MATCHES';
}

Expand All @@ -367,11 +378,18 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
dependencies.changedFilesBetween ?? ((base, head) => changedFilesBetween(base, head, cwd));
const matchesWorktree =
dependencies.worktreeMatchesCommit ?? ((sha) => computeWorkingTreeState(sha, cwd));
const matchesDependencyState =
dependencies.dependencyStateForRef ?? ((sha) => computeDependencyState(sha, cwd));
Comment thread
qnbs marked this conversation as resolved.
const changedFiles = new Set();
const evidenceUpdates = [];
for (const update of updates) {
if (isZeroSha(update.localSha)) {
evidenceUpdates.push({ ...update, disposition: 'DELETED', workingTreeState: 'NOT_APPLICABLE' });
evidenceUpdates.push({
...update,
disposition: 'DELETED',
workingTreeState: 'NOT_APPLICABLE',
dependencyState: 'NOT_APPLICABLE',
});
continue;
}
if (update.remoteRef.startsWith('refs/tags/')) {
Expand All @@ -380,7 +398,8 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
evidenceUpdates.push({
...update,
disposition: 'TAG',
workingTreeState: matchesWorktree(update.localSha),
workingTreeState: safeDiagnostic(matchesWorktree, update.localSha),
dependencyState: safeDiagnostic(matchesDependencyState, update.localSha),
});
continue;
}
Expand All @@ -394,7 +413,8 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
...update,
base,
disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED',
workingTreeState: matchesWorktree(update.localSha),
workingTreeState: safeDiagnostic(matchesWorktree, update.localSha),
dependencyState: safeDiagnostic(matchesDependencyState, update.localSha),
});
}
// QNBS-v3: tag updates prove object validity but not a complete changed-path set.
Expand All @@ -406,7 +426,8 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
changedFiles: [...changedFiles],
evidenceState: 'RESOLVED',
pathEvidenceState,
workingTreeState: aggregateWorkingTreeState(evidenceUpdates),
workingTreeState: aggregateDiagnosticState(evidenceUpdates.map((u) => u.workingTreeState)),
dependencyState: aggregateDiagnosticState(evidenceUpdates.map((u) => u.dependencyState)),
};
} catch (error) {
return {
Expand All @@ -415,6 +436,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
evidenceState: 'INVALID',
pathEvidenceState: 'PARTIAL',
workingTreeState: 'NOT_APPLICABLE',
dependencyState: 'NOT_APPLICABLE',
reason: error instanceof Error ? error.message : 'invalid push evidence',
};
}
Expand Down
Loading
Loading