diff --git a/README.md b/README.md index fe5022ae..a36bc246 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 6959+ tests / 575 files + 6963+ tests / 575 files Codecov Coverage License MIT CI Status @@ -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 (6959+ tests / 575 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6963+ 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` | @@ -550,7 +550,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (6959+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6963+ tests, 575 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -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):** -- **6959+ unit tests** across **575 test files** — CI is authoritative for pass/fail +- **6963+ 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) diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts index 8ccec839..4036d23a 100644 --- a/scripts/signing/signing-core.d.mts +++ b/scripts/signing/signing-core.d.mts @@ -87,6 +87,7 @@ export interface PushEvidence { updates: PushEvidenceUpdate[]; changedFiles: string[]; evidenceState: 'RESOLVED' | 'INVALID'; + pathEvidenceState: 'COMPLETE' | 'PARTIAL'; reason?: string; } export function resolvePushEvidence( diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs index 7122ed82..918169f4 100644 --- a/scripts/signing/signing-core.mjs +++ b/scripts/signing/signing-core.mjs @@ -366,12 +366,22 @@ export function resolvePushEvidence(input, cwd = process.cwd(), dependencies = { disposition: isZeroSha(update.remoteSha) ? 'NEW_BRANCH' : 'UPDATED', }); } - return { updates: evidenceUpdates, changedFiles: [...changedFiles], evidenceState: 'RESOLVED' }; + // QNBS-v3: tag updates prove object validity but not a complete changed-path set. + const pathEvidenceState = evidenceUpdates.some(({ disposition }) => disposition === 'TAG') + ? 'PARTIAL' + : 'COMPLETE'; + return { + updates: evidenceUpdates, + changedFiles: [...changedFiles], + evidenceState: 'RESOLVED', + pathEvidenceState, + }; } catch (error) { return { updates: [], changedFiles: [], evidenceState: 'INVALID', + pathEvidenceState: 'PARTIAL', reason: error instanceof Error ? error.message : 'invalid push evidence', }; } diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts index 18c0d3a0..cae51aa4 100644 --- a/tests/unit/signing.test.ts +++ b/tests/unit/signing.test.ts @@ -220,6 +220,7 @@ describe('local signing controls', () => { : ['src/with\t tab.ts', '世界 file.ts', 'src/with\t tab.ts'], }); expect(result.evidenceState).toBe('RESOLVED'); + expect(result.pathEvidenceState).toBe('PARTIAL'); expect(result.updates.map(({ disposition }) => disposition)).toEqual([ 'UPDATED', 'NEW_BRANCH', @@ -229,6 +230,73 @@ describe('local signing controls', () => { expect(result.changedFiles).toEqual(['src/with\t tab.ts', '世界 file.ts', 'new\nfile.ts']); }); + // QNBS-v3: branch/new-branch/deletion diff real content; tags can't without inventing paths. + it('reports complete path evidence for branch, new-branch, and deletion updates', () => { + const zero = '0'.repeat(40); + const updates = [ + parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`)!, + parseRefUpdate(`refs/heads/new ${'c'.repeat(40)} refs/heads/new ${zero}`)!, + parseRefUpdate(`refs/heads/deleted ${zero} refs/heads/deleted ${'d'.repeat(40)}`)!, + ]; + const result = resolvePushEvidence(updates, process.cwd(), { + commitExists: () => true, + changedFilesBetween: () => ['src/example.ts'], + }); + + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.pathEvidenceState).toBe('COMPLETE'); + }); + + it('marks lightweight and annotated tag evidence partial without inventing paths', () => { + const commit = 'a'.repeat(40); + const lightweight = parseRefUpdate(`refs/tags/v1 ${commit} refs/tags/v1 ${'0'.repeat(40)}`)!; + const annotated = parseRefUpdate( + `refs/tags/v2 ${'b'.repeat(40)} refs/tags/v2 ${'c'.repeat(40)}`, + )!; + + for (const update of [lightweight, annotated]) { + const result = resolvePushEvidence([update], process.cwd(), { + objectExists: () => true, + }); + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.pathEvidenceState).toBe('PARTIAL'); + expect(result.changedFiles).toEqual([]); + } + }); + + it('marks mixed branch and tag evidence partial', () => { + const zero = '0'.repeat(40); + const result = resolvePushEvidence( + [ + parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`)!, + parseRefUpdate(`refs/tags/v1 ${'c'.repeat(40)} refs/tags/v1 ${zero}`)!, + ], + process.cwd(), + { + commitExists: () => true, + objectExists: () => true, + changedFilesBetween: () => ['src/a.ts'], + }, + ); + + expect(result.evidenceState).toBe('RESOLVED'); + expect(result.pathEvidenceState).toBe('PARTIAL'); + expect(result.changedFiles).toEqual(['src/a.ts']); + }); + + it('keeps empty evidence complete and rejects an unavailable tag object', () => { + expect(resolvePushEvidence([]).pathEvidenceState).toBe('COMPLETE'); + + const result = resolvePushEvidence( + [parseRefUpdate(`refs/tags/v1 ${'a'.repeat(40)} refs/tags/v1 ${'0'.repeat(40)}`)!], + process.cwd(), + { objectExists: () => false }, + ); + + expect(result.evidenceState).toBe('INVALID'); + expect(result.pathEvidenceState).toBe('PARTIAL'); + }); + it('fails closed for missing objects, bases, Git failures, and unsupported evidence', () => { const update = parseRefUpdate( `refs/heads/main ${'a'.repeat(40)} refs/heads/main ${'b'.repeat(40)}`,