diff --git a/README.md b/README.md
index fe5022ae..a36bc246 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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)}`,