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-6998%2B_%2F_578_files-22C55E" alt="6998+ tests / 578 files">
<img src="https://img.shields.io/badge/Tests-7003%2B_%2F_578_files-22C55E" alt="7003+ 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 (7003+ 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 (7003+ 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
- **7003+ 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) => string | 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;
95 changes: 90 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,93 @@ export function dependencyFiles(root = projectRoot) {
return files.filter((file) => existsSync(file)).sort();
}

export function calculateDependencyFingerprint(root = projectRoot) {
// 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`);
hash.update(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);
}

function defaultListTreeFiles(sha, cwd) {
const result = spawnSync('git', ['ls-tree', '-r', '--name-only', sha], {
cwd,
encoding: 'utf8',
timeout: 5000,
});
Comment thread
qnbs marked this conversation as resolved.
Outdated
if (result.error || result.status !== 0) return null;
return result.stdout.split('\n').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();
}

function defaultReadFileAtRef(sha, relativePath, cwd) {
const result = spawnSync('git', ['show', `${sha}:${relativePath}`], {
cwd,
encoding: 'utf8',
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
25 changes: 19 additions & 6 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,12 @@ export function computeWorkingTreeState(sha, cwd, dependencies = {}) {
return 'UNKNOWN';
}

function aggregateWorkingTreeState(evidenceUpdates) {
const relevant = evidenceUpdates.filter((update) => update.workingTreeState !== 'NOT_APPLICABLE');
// 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 +369,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 @@ -381,6 +390,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
...update,
disposition: 'TAG',
workingTreeState: matchesWorktree(update.localSha),
dependencyState: matchesDependencyState(update.localSha),
});
continue;
}
Expand All @@ -395,6 +405,7 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = {
base,
disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED',
workingTreeState: matchesWorktree(update.localSha),
dependencyState: matchesDependencyState(update.localSha),
});
}
// QNBS-v3: tag updates prove object validity but not a complete changed-path set.
Expand All @@ -406,7 +417,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 +427,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